From e70bb6b169cfef7024686e60780c2b3eb95b461f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 23 Jul 2026 14:01:31 +0200 Subject: [PATCH 01/37] port spec/gloas type surface + versioned wrappers + api/v1/gloas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive Gloas (Glamsterdam devnet-7) type surface ported from the ethpandaops fork with imports rewritten to attestantio, additive-only. Target spec consensus v1.7.0-alpha.12. What landed: - spec/version restructure: DataVersion/BuilderVersion moved to a leaf spec/version package with an alias shim in package spec. Added DataVersionGloas. Heze trimmed (out of scope). - spec/gloas package (92 source files) + SSZ regenerated with dynssz-gen (go generate ./spec/gloas), byte-identical to the fork output. - Gloas arms added to 10 existing Versioned* wrappers (additive only). - 4 new ePBS versioned wrappers: VersionedExecutionPayloadEnvelope, VersionedSignedExecutionPayloadEnvelope, VersionedSignedExecutionPayloadBid, VersionedExecutionRequests. - api/v1/gloas: SignedExecutionPayloadEnvelopeContents (+ codecs, SSZ regen). Key decisions: - Used const aliases (not the fork's var) in spec/version.go to preserve the original const semantics; preserved spec.DataVersionFromString, which the fork drops (kept additive). - Kept the fork's ssz-index struct tags: they are load-bearing for dynssz-gen (removing them changes the generated SSZ output — verified empirically), even though attestantio's other forks use positional SSZ. - Excluded the fork's breaking changes: no BaseFeePerGas -> BaseFeePerGasLE rename (bellatrix/capella arms untouched); preserved VersionedSignedBeaconBlock.ExecutionRequests() returning *electra.ExecutionRequests (gloas arm returns an explicit error). Heze omitted entirely. - Lint: added //nolint:gocyclo to the version-switch methods that exceeded the complexity threshold once the gloas case was added (matches the repo's existing convention). Added a scoped .golangci.yml exclusion for line-length (lll + revive) on the gloas type packages: gloas ePBS types carry long dynssz size-expression tags that gofmt aligns past 132 chars and that cannot be wrapped, and the ssz-index tags cannot be dropped. Files changed: - new: spec/gloas/ (92 files), spec/version/, spec/version.go, api/v1/gloas/, spec/versioned{executionpayloadenvelope,executionrequests, signedexecutionpayloadbid,signedexecutionpayloadenvelope}.go - modified: 10 spec/versioned*.go wrappers, .golangci.yml - deleted: spec/dataversion.go, spec/builderversion.go (moved to spec/version/) Verified: go build ./...; go test ./... (2168 tests); changed packages golangci-lint clean; SSZ regen idempotent and byte-identical to fork. Blockers / notes for next iteration: - Acceptance criterion "gloas ssz_static consensus-spec tests (v1.7.0-alpha.12)" is BLOCKED: ethereum/consensus-spec-tests has not published v1.7.0-alpha.12 (latest release is v1.6.0-beta.0, predating gloas). The ported spec/gloas/consensusspec_test.go skips gracefully until CONSENSUS_SPEC_TESTS_DIR points at gloas ssz_static vectors. - Acceptance criterion "devnet-7 block and state decode (demo)" is BLOCKED: needs a Gloas-capable endpoint / devnet-7 fixtures (a Kurtosis devnet). - Pre-existing, unrelated: spec/capella/generate.go has an unused //nolint:revive (nolintlint) — not touched by this change. --- .golangci.yml | 12 + api/v1/gloas/generate.go | 17 + api/v1/gloas/generate.yaml | 7 + .../signedexecutionpayloadenvelopecontents.go | 43 + ...edexecutionpayloadenvelopecontents_json.go | 61 + ...nedexecutionpayloadenvelopecontents_ssz.go | 208 +++ ...edexecutionpayloadenvelopecontents_yaml.go | 61 + spec/gloas/aggregateandproof.go | 137 ++ spec/gloas/aggregateandproof_ssz.go | 133 ++ spec/gloas/attestation.go | 205 ++ spec/gloas/attestation_ssz.go | 186 ++ spec/gloas/attestation_test.go | 192 ++ spec/gloas/attesterslashing.go | 107 ++ spec/gloas/attesterslashing_ssz.go | 148 ++ spec/gloas/beaconblock.go | 40 + spec/gloas/beaconblock_json.go | 69 + spec/gloas/beaconblock_ssz.go | 155 ++ spec/gloas/beaconblock_yaml.go | 53 + spec/gloas/beaconblockbody.go | 50 + spec/gloas/beaconblockbody_json.go | 109 ++ spec/gloas/beaconblockbody_ssz.go | 712 +++++++ spec/gloas/beaconblockbody_yaml.go | 61 + spec/gloas/beaconstate.go | 85 + spec/gloas/beaconstate_json.go | 461 +++++ spec/gloas/beaconstate_ssz.go | 1661 +++++++++++++++++ spec/gloas/beaconstate_yaml.go | 150 ++ spec/gloas/builder.go | 188 ++ spec/gloas/builder_ssz.go | 130 ++ spec/gloas/builderdepositrequest.go | 39 + spec/gloas/builderdepositrequest_json.go | 110 ++ spec/gloas/builderdepositrequest_ssz.go | 126 ++ spec/gloas/builderdepositrequest_yaml.go | 55 + spec/gloas/builderexitrequest.go | 38 + spec/gloas/builderexitrequest_json.go | 55 + spec/gloas/builderexitrequest_ssz.go | 87 + spec/gloas/builderexitrequest_yaml.go | 60 + spec/gloas/builderindex.go | 58 + spec/gloas/builderpendingpayment.go | 38 + spec/gloas/builderpendingpayment_ssz.go | 117 ++ spec/gloas/builderpendingwithdrawal.go | 39 + spec/gloas/builderpendingwithdrawal_ssz.go | 100 + spec/gloas/consensusspec_test.go | 350 ++++ spec/gloas/executionpayload.go | 60 + spec/gloas/executionpayload_json.go | 311 +++ spec/gloas/executionpayload_ssz.go | 460 +++++ spec/gloas/executionpayload_yaml.go | 109 ++ spec/gloas/executionpayloadbid.go | 49 + spec/gloas/executionpayloadbid_json.go | 201 ++ spec/gloas/executionpayloadbid_ssz.go | 233 +++ spec/gloas/executionpayloadbid_yaml.go | 65 + spec/gloas/executionpayloadenvelope.go | 40 + spec/gloas/executionpayloadenvelope_json.go | 91 + spec/gloas/executionpayloadenvelope_ssz.go | 181 ++ spec/gloas/executionpayloadenvelope_yaml.go | 53 + spec/gloas/executionrequests.go | 40 + spec/gloas/executionrequests_json.go | 103 + spec/gloas/executionrequests_ssz.go | 379 ++++ spec/gloas/executionrequests_yaml.go | 65 + spec/gloas/generate.go | 17 + spec/gloas/generate.yaml | 57 + spec/gloas/indexedattestation.go | 147 ++ spec/gloas/indexedattestation_ssz.go | 147 ++ spec/gloas/indexedpayloadattestation.go | 38 + spec/gloas/indexedpayloadattestation_json.go | 79 + spec/gloas/indexedpayloadattestation_ssz.go | 155 ++ spec/gloas/indexedpayloadattestation_yaml.go | 55 + spec/gloas/payloadattestation.go | 39 + spec/gloas/payloadattestation_json.go | 72 + spec/gloas/payloadattestation_ssz.go | 130 ++ spec/gloas/payloadattestation_yaml.go | 51 + spec/gloas/payloadattestationdata.go | 39 + spec/gloas/payloadattestationdata_json.go | 72 + spec/gloas/payloadattestationdata_ssz.go | 116 ++ spec/gloas/payloadattestationdata_yaml.go | 52 + spec/gloas/payloadattestationmessage.go | 38 + spec/gloas/payloadattestationmessage_json.go | 74 + spec/gloas/payloadattestationmessage_ssz.go | 117 ++ spec/gloas/payloadattestationmessage_yaml.go | 51 + spec/gloas/proposerpreferences.go | 41 + spec/gloas/proposerpreferences_json.go | 108 ++ spec/gloas/proposerpreferences_ssz.go | 120 ++ spec/gloas/proposerpreferences_yaml.go | 53 + spec/gloas/signedaggregateandproof.go | 120 ++ spec/gloas/signedaggregateandproof_ssz.go | 121 ++ spec/gloas/signedbeaconblock.go | 37 + spec/gloas/signedbeaconblock_json.go | 64 + spec/gloas/signedbeaconblock_ssz.go | 121 ++ spec/gloas/signedbeaconblock_yaml.go | 55 + spec/gloas/signedexecutionpayloadbid.go | 37 + spec/gloas/signedexecutionpayloadbid_json.go | 61 + spec/gloas/signedexecutionpayloadbid_ssz.go | 121 ++ spec/gloas/signedexecutionpayloadbid_yaml.go | 50 + spec/gloas/signedexecutionpayloadenvelope.go | 37 + .../signedexecutionpayloadenvelope_json.go | 61 + .../signedexecutionpayloadenvelope_ssz.go | 121 ++ .../signedexecutionpayloadenvelope_yaml.go | 50 + spec/gloas/signedproposerpreferences.go | 37 + spec/gloas/signedproposerpreferences_json.go | 61 + spec/gloas/signedproposerpreferences_ssz.go | 104 ++ spec/gloas/signedproposerpreferences_yaml.go | 50 + spec/version.go | 47 + spec/{ => version}/builderversion.go | 2 +- spec/{ => version}/dataversion.go | 6 +- spec/versionedaggregateandproof.go | 27 +- spec/versionedattestation.go | 46 +- spec/versionedattesterslashing.go | 32 +- spec/versionedbeaconblock.go | 138 +- spec/versionedbeaconblockbody.go | 8 + spec/versionedbeaconstate.go | 146 +- spec/versionedexecutionpayload.go | 112 +- spec/versionedexecutionpayloadenvelope.go | 194 ++ spec/versionedexecutionrequests.go | 182 ++ spec/versionedindexedattestation.go | 28 +- spec/versionedsignedaggregateandproof.go | 33 +- spec/versionedsignedbeaconblock.go | 195 ++ spec/versionedsignedexecutionpayloadbid.go | 355 ++++ ...versionedsignedexecutionpayloadenvelope.go | 251 +++ 117 files changed, 14121 insertions(+), 10 deletions(-) create mode 100644 api/v1/gloas/generate.go create mode 100644 api/v1/gloas/generate.yaml create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go create mode 100644 spec/gloas/aggregateandproof.go create mode 100644 spec/gloas/aggregateandproof_ssz.go create mode 100644 spec/gloas/attestation.go create mode 100644 spec/gloas/attestation_ssz.go create mode 100644 spec/gloas/attestation_test.go create mode 100644 spec/gloas/attesterslashing.go create mode 100644 spec/gloas/attesterslashing_ssz.go create mode 100644 spec/gloas/beaconblock.go create mode 100644 spec/gloas/beaconblock_json.go create mode 100644 spec/gloas/beaconblock_ssz.go create mode 100644 spec/gloas/beaconblock_yaml.go create mode 100644 spec/gloas/beaconblockbody.go create mode 100644 spec/gloas/beaconblockbody_json.go create mode 100644 spec/gloas/beaconblockbody_ssz.go create mode 100644 spec/gloas/beaconblockbody_yaml.go create mode 100644 spec/gloas/beaconstate.go create mode 100644 spec/gloas/beaconstate_json.go create mode 100644 spec/gloas/beaconstate_ssz.go create mode 100644 spec/gloas/beaconstate_yaml.go create mode 100644 spec/gloas/builder.go create mode 100644 spec/gloas/builder_ssz.go create mode 100644 spec/gloas/builderdepositrequest.go create mode 100644 spec/gloas/builderdepositrequest_json.go create mode 100644 spec/gloas/builderdepositrequest_ssz.go create mode 100644 spec/gloas/builderdepositrequest_yaml.go create mode 100644 spec/gloas/builderexitrequest.go create mode 100644 spec/gloas/builderexitrequest_json.go create mode 100644 spec/gloas/builderexitrequest_ssz.go create mode 100644 spec/gloas/builderexitrequest_yaml.go create mode 100644 spec/gloas/builderindex.go create mode 100644 spec/gloas/builderpendingpayment.go create mode 100644 spec/gloas/builderpendingpayment_ssz.go create mode 100644 spec/gloas/builderpendingwithdrawal.go create mode 100644 spec/gloas/builderpendingwithdrawal_ssz.go create mode 100644 spec/gloas/consensusspec_test.go create mode 100644 spec/gloas/executionpayload.go create mode 100644 spec/gloas/executionpayload_json.go create mode 100644 spec/gloas/executionpayload_ssz.go create mode 100644 spec/gloas/executionpayload_yaml.go create mode 100644 spec/gloas/executionpayloadbid.go create mode 100644 spec/gloas/executionpayloadbid_json.go create mode 100644 spec/gloas/executionpayloadbid_ssz.go create mode 100644 spec/gloas/executionpayloadbid_yaml.go create mode 100644 spec/gloas/executionpayloadenvelope.go create mode 100644 spec/gloas/executionpayloadenvelope_json.go create mode 100644 spec/gloas/executionpayloadenvelope_ssz.go create mode 100644 spec/gloas/executionpayloadenvelope_yaml.go create mode 100644 spec/gloas/executionrequests.go create mode 100644 spec/gloas/executionrequests_json.go create mode 100644 spec/gloas/executionrequests_ssz.go create mode 100644 spec/gloas/executionrequests_yaml.go create mode 100644 spec/gloas/generate.go create mode 100644 spec/gloas/generate.yaml create mode 100644 spec/gloas/indexedattestation.go create mode 100644 spec/gloas/indexedattestation_ssz.go create mode 100644 spec/gloas/indexedpayloadattestation.go create mode 100644 spec/gloas/indexedpayloadattestation_json.go create mode 100644 spec/gloas/indexedpayloadattestation_ssz.go create mode 100644 spec/gloas/indexedpayloadattestation_yaml.go create mode 100644 spec/gloas/payloadattestation.go create mode 100644 spec/gloas/payloadattestation_json.go create mode 100644 spec/gloas/payloadattestation_ssz.go create mode 100644 spec/gloas/payloadattestation_yaml.go create mode 100644 spec/gloas/payloadattestationdata.go create mode 100644 spec/gloas/payloadattestationdata_json.go create mode 100644 spec/gloas/payloadattestationdata_ssz.go create mode 100644 spec/gloas/payloadattestationdata_yaml.go create mode 100644 spec/gloas/payloadattestationmessage.go create mode 100644 spec/gloas/payloadattestationmessage_json.go create mode 100644 spec/gloas/payloadattestationmessage_ssz.go create mode 100644 spec/gloas/payloadattestationmessage_yaml.go create mode 100644 spec/gloas/proposerpreferences.go create mode 100644 spec/gloas/proposerpreferences_json.go create mode 100644 spec/gloas/proposerpreferences_ssz.go create mode 100644 spec/gloas/proposerpreferences_yaml.go create mode 100644 spec/gloas/signedaggregateandproof.go create mode 100644 spec/gloas/signedaggregateandproof_ssz.go create mode 100644 spec/gloas/signedbeaconblock.go create mode 100644 spec/gloas/signedbeaconblock_json.go create mode 100644 spec/gloas/signedbeaconblock_ssz.go create mode 100644 spec/gloas/signedbeaconblock_yaml.go create mode 100644 spec/gloas/signedexecutionpayloadbid.go create mode 100644 spec/gloas/signedexecutionpayloadbid_json.go create mode 100644 spec/gloas/signedexecutionpayloadbid_ssz.go create mode 100644 spec/gloas/signedexecutionpayloadbid_yaml.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_json.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_ssz.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_yaml.go create mode 100644 spec/gloas/signedproposerpreferences.go create mode 100644 spec/gloas/signedproposerpreferences_json.go create mode 100644 spec/gloas/signedproposerpreferences_ssz.go create mode 100644 spec/gloas/signedproposerpreferences_yaml.go create mode 100644 spec/version.go rename spec/{ => version}/builderversion.go (99%) rename spec/{ => version}/dataversion.go (94%) create mode 100644 spec/versionedexecutionpayloadenvelope.go create mode 100644 spec/versionedexecutionrequests.go create mode 100644 spec/versionedsignedexecutionpayloadbid.go create mode 100644 spec/versionedsignedexecutionpayloadenvelope.go diff --git a/.golangci.yml b/.golangci.yml index fa3b002c7..8e395ffa0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -178,6 +178,18 @@ 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: + - lll + - revive # Formatters: Code formatting tools formatters: enable: diff --git a/api/v1/gloas/generate.go b/api/v1/gloas/generate.go new file mode 100644 index 000000000..b3dc24402 --- /dev/null +++ b/api/v1/gloas/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +//go:generate rm -f *_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/api/v1/gloas/generate.yaml b/api/v1/gloas/generate.yaml new file mode 100644 index 000000000..afca147b0 --- /dev/null +++ b/api/v1/gloas/generate.yaml @@ -0,0 +1,7 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: SignedExecutionPayloadEnvelopeContents + output: signedexecutionpayloadenvelopecontents_ssz.go diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents.go new file mode 100644 index 000000000..431cc2df3 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents.go @@ -0,0 +1,43 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadEnvelopeContents is the stateless request body for +// publishing an execution payload envelope: the signed envelope together with +// the blobs and KZG cell proofs the beacon node needs to build data column +// sidecars when it has none cached from its own block production. +type SignedExecutionPayloadEnvelopeContents struct { + SignedExecutionPayloadEnvelope *gloas.SignedExecutionPayloadEnvelope + + KZGProofs []deneb.KZGProof `dynssz-max:"FIELD_ELEMENTS_PER_EXT_BLOB*MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"33554432" ssz-size:"?,48"` + Blobs []deneb.Blob `dynssz-max:"MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"4096" ssz-size:"?,131072"` +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadEnvelopeContents) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go new file mode 100644 index 000000000..d59ebde30 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go @@ -0,0 +1,61 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/pkg/errors" +) + +// 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{ + SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, + KZGProofs: s.KZGProofs, + Blobs: s.Blobs, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&signedExecutionPayloadEnvelopeContentsJSON{}, input) + if err != nil { + return err + } + + if err := json.Unmarshal(raw["signed_execution_payload_envelope"], &s.SignedExecutionPayloadEnvelope); err != nil { + return errors.Wrap(err, "signed_execution_payload_envelope") + } + + if err := json.Unmarshal(raw["kzg_proofs"], &s.KZGProofs); err != nil { + return errors.Wrap(err, "kzg_proofs") + } + + if err := json.Unmarshal(raw["blobs"], &s.Blobs); err != nil { + return errors.Wrap(err, "blobs") + } + + return nil +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go new file mode 100644 index 000000000..e7b173842 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go @@ -0,0 +1,208 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 771e09a081cfdc07ad7cb8c1f0425aa4c536ac0e215d4df6d99dc827920ef840 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadEnvelopeContents](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadEnvelopeContents to SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelopeContents) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadEnvelopeContents to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadEnvelopeContents) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + dstlen := len(dst) + // Offset Field #0 'SignedExecutionPayloadEnvelope' + // Offset Field #1 'KZGProofs' + // Offset Field #2 'Blobs' + dst = append(dst, zeroBytes[:12]...) + { // Dynamic Field #0 'SignedExecutionPayloadEnvelope' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.SignedExecutionPayloadEnvelope + if t == nil { + t = new(gloas.SignedExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Dynamic Field #1 'KZGProofs' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.KZGProofs + vlen := len(t) + if vlen > 33554432 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #2 'Blobs' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Blobs + vlen := len(t) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadEnvelopeContents from SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelopeContents) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'SignedExecutionPayloadEnvelope' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 12), "SignedExecutionPayloadEnvelope:o") + } + // Field #1 'KZGProofs' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "KZGProofs:o") + } + // Field #2 'Blobs' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "Blobs:o") + } + { // Field #0 'SignedExecutionPayloadEnvelope' (dynamic) + buf := buf[offset0:offset1] + if t.SignedExecutionPayloadEnvelope == nil { + t.SignedExecutionPayloadEnvelope = new(gloas.SignedExecutionPayloadEnvelope) + } + if err = t.SignedExecutionPayloadEnvelope.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Field #1 'KZGProofs' (dynamic) + buf := buf[offset1:offset2] + val1 := t.KZGProofs + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "KZGProofs") + } + if itemCount > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 33554432), "KZGProofs") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.KZGProofs = val1 + } + { // Field #2 'Blobs' (dynamic) + buf := buf[offset2:] + val2 := t.Blobs + itemCount := len(buf) / 131072 + if len(buf)%131072 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 131072), "Blobs") + } + if itemCount > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 4096), "Blobs") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + sszutils.UnmarshalFixedBytesSlice(val2[:itemCount], buf) + t.Blobs = val2 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadEnvelopeContents. +func (t *SignedExecutionPayloadEnvelopeContents) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + // Field #0 'SignedExecutionPayloadEnvelope' offset (4 bytes) + // Field #1 'KZGProofs' offset (4 bytes) + // Field #2 'Blobs' offset (4 bytes) + size += 12 + { // Dynamic field #0 'SignedExecutionPayloadEnvelope' + size += t.SignedExecutionPayloadEnvelope.SizeSSZ() + } + { // Dynamic field #1 'KZGProofs' + size += len(t.KZGProofs) * 48 + } + { // Dynamic field #2 'Blobs' + size += len(t.Blobs) * 131072 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelopeContents. +func (t *SignedExecutionPayloadEnvelopeContents) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelopeContents using the given hash walker. +func (t *SignedExecutionPayloadEnvelopeContents) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'SignedExecutionPayloadEnvelope' + t := t.SignedExecutionPayloadEnvelope + if t == nil { + t = new(gloas.SignedExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Field #1 'KZGProofs' + t := t.KZGProofs + vlen := uint64(len(t)) + if vlen > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(33554432, vlen, 32)) + } + { // Field #2 'Blobs' + t := t.Blobs + vlen := uint64(len(t)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:131072]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 32)) + } + hh.Merkleize(idx) + return nil +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go new file mode 100644 index 000000000..bea17d100 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go @@ -0,0 +1,61 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// 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{ + SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, + KZGProofs: s.KZGProofs, + Blobs: s.Blobs, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var unmarshaled signedExecutionPayloadEnvelopeContentsJSON + 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 s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/aggregateandproof.go b/spec/gloas/aggregateandproof.go new file mode 100644 index 000000000..112d0eeba --- /dev/null +++ b/spec/gloas/aggregateandproof.go @@ -0,0 +1,137 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// AggregateAndProof is the Ethereum 2 attestation structure. +type AggregateAndProof struct { + AggregatorIndex phase0.ValidatorIndex + Aggregate *Attestation + SelectionProof phase0.BLSSignature `ssz-size:"96"` +} + +// aggregateAndProofJSON is the spec representation of the struct. +type aggregateAndProofJSON struct { + AggregatorIndex string `json:"aggregator_index"` + Aggregate *Attestation `json:"aggregate"` + SelectionProof string `json:"selection_proof"` +} + +// aggregateAndProofYAML is the spec representation of the struct. +type aggregateAndProofYAML struct { + AggregatorIndex uint64 `yaml:"aggregator_index"` + Aggregate *Attestation `yaml:"aggregate"` + SelectionProof string `yaml:"selection_proof"` +} + +// MarshalJSON implements json.Marshaler. +func (a *AggregateAndProof) MarshalJSON() ([]byte, error) { + return json.Marshal(&aggregateAndProofJSON{ + AggregatorIndex: fmt.Sprintf("%d", a.AggregatorIndex), + Aggregate: a.Aggregate, + SelectionProof: fmt.Sprintf("%#x", a.SelectionProof), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *AggregateAndProof) UnmarshalJSON(input []byte) error { + var aggregateAndProofJSON aggregateAndProofJSON + if err := json.Unmarshal(input, &aggregateAndProofJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&aggregateAndProofJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (a *AggregateAndProof) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&aggregateAndProofYAML{ + AggregatorIndex: uint64(a.AggregatorIndex), + Aggregate: a.Aggregate, + SelectionProof: fmt.Sprintf("%#x", a.SelectionProof), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *AggregateAndProof) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var aggregateAndProofJSON aggregateAndProofJSON + if err := yaml.Unmarshal(input, &aggregateAndProofJSON); err != nil { + return err + } + + return a.unpack(&aggregateAndProofJSON) +} + +// String returns a string version of the structure. +func (a *AggregateAndProof) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (a *AggregateAndProof) unpack(aggregateAndProofJSON *aggregateAndProofJSON) error { + if aggregateAndProofJSON.AggregatorIndex == "" { + return errors.New("aggregator index missing") + } + + aggregatorIndex, err := strconv.ParseUint(aggregateAndProofJSON.AggregatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for aggregator index") + } + + a.AggregatorIndex = phase0.ValidatorIndex(aggregatorIndex) + + if aggregateAndProofJSON.Aggregate == nil { + return errors.New("aggregate missing") + } + + a.Aggregate = aggregateAndProofJSON.Aggregate + if aggregateAndProofJSON.SelectionProof == "" { + return errors.New("selection proof missing") + } + + selectionProof, err := hex.DecodeString(strings.TrimPrefix(aggregateAndProofJSON.SelectionProof, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for selection proof") + } + + if len(selectionProof) != phase0.SignatureLength { + return errors.New("incorrect length for selection proof") + } + + copy(a.SelectionProof[:], selectionProof) + + return nil +} diff --git a/spec/gloas/aggregateandproof_ssz.go b/spec/gloas/aggregateandproof_ssz.go new file mode 100644 index 000000000..abe5e047d --- /dev/null +++ b/spec/gloas/aggregateandproof_ssz.go @@ -0,0 +1,133 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 5ab124fd14fc73ebf073ec3d655828afa31b5a378eeb87325eaa26a64b59a0f3 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[AggregateAndProof](`ssz-static:"false"`) + +// MarshalSSZ marshals the *AggregateAndProof to SSZ-encoded bytes. +func (t *AggregateAndProof) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *AggregateAndProof to SSZ-encoded bytes, appending to the provided buffer. +func (t *AggregateAndProof) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(AggregateAndProof) + } + dstlen := len(dst) + { // Static Field #0 'AggregatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.AggregatorIndex)) + } + // Offset Field #1 'Aggregate' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #2 'SelectionProof' + dst = append(dst, t.SelectionProof[:96]...) + } + { // Dynamic Field #1 'Aggregate' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Aggregate + if t == nil { + t = new(Attestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Aggregate") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *AggregateAndProof from SSZ-encoded bytes. +func (t *AggregateAndProof) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 108 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 108) + } + { // Field #0 'AggregatorIndex' (static) + buf := buf[0:8] + t.AggregatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + // Field #1 'Aggregate' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset1 != 108 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset1, 108), "Aggregate:o") + } + { // Field #2 'SelectionProof' (static) + buf := buf[12:108] + copy(t.SelectionProof[:], buf) + } + { // Field #1 'Aggregate' (dynamic) + buf := buf[offset1:] + if t.Aggregate == nil { + t.Aggregate = new(Attestation) + } + if err = t.Aggregate.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Aggregate") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *AggregateAndProof. +func (t *AggregateAndProof) SizeSSZ() (size int) { + if t == nil { + t = new(AggregateAndProof) + } + // Field #0 'AggregatorIndex' static (8 bytes) + // Field #1 'Aggregate' offset (4 bytes) + // Field #2 'SelectionProof' static (96 bytes) + size += 108 + { // Dynamic field #1 'Aggregate' + size += t.Aggregate.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *AggregateAndProof. +func (t *AggregateAndProof) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *AggregateAndProof using the given hash walker. +func (t *AggregateAndProof) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(AggregateAndProof) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregatorIndex' + hh.PutUint64(uint64(t.AggregatorIndex)) + } + { // Field #1 'Aggregate' + t := t.Aggregate + if t == nil { + t = new(Attestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Aggregate") + } + } + { // Field #2 'SelectionProof' + hh.PutBytes(t.SelectionProof[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/attestation.go b/spec/gloas/attestation.go new file mode 100644 index 000000000..cd28b2cf9 --- /dev/null +++ b/spec/gloas/attestation.go @@ -0,0 +1,205 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// Attestation is the Ethereum 2 attestation structure. +type Attestation struct { + AggregationBits bitfield.Bitlist `ssz-index:"0" ssz-type:"progressive-bitlist"` + Data *phase0.AttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` + CommitteeBits bitfield.Bitvector64 `dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-index:"3" ssz-size:"8"` +} + +// attestationJSON is a raw representation of the struct. +type attestationJSON struct { + AggregationBits string `json:"aggregation_bits"` + Data *phase0.AttestationData `json:"data"` + Signature string `json:"signature"` + CommitteeBits string `json:"committee_bits"` +} + +// attestationYAML is a raw representation of the struct. +type attestationYAML struct { + AggregationBits string `yaml:"aggregation_bits"` + Data *phase0.AttestationData `yaml:"data"` + Signature string `yaml:"signature"` + CommitteeBits string `yaml:"committee_bits"` +} + +// MarshalJSON implements json.Marshaler. +func (a *Attestation) MarshalJSON() ([]byte, error) { + return json.Marshal(&attestationJSON{ + AggregationBits: fmt.Sprintf("%#x", []byte(a.AggregationBits)), + Data: a.Data, + Signature: fmt.Sprintf("%#x", a.Signature), + CommitteeBits: fmt.Sprintf("%#x", a.CommitteeBits), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *Attestation) UnmarshalJSON(input []byte) error { + var attestationJSON attestationJSON + + err := json.Unmarshal(input, &attestationJSON) + if err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&attestationJSON) +} + +func (a *Attestation) unpack(attestationJSON *attestationJSON) error { + var err error + + if attestationJSON.AggregationBits == "" { + return errors.New("aggregation bits missing") + } + + if a.AggregationBits, err = hex.DecodeString(strings.TrimPrefix(attestationJSON.AggregationBits, "0x")); err != nil { + return errors.Wrap(err, "invalid value for aggregation bits") + } + + a.Data = attestationJSON.Data + if a.Data == nil { + return errors.New("data missing") + } + + if attestationJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(attestationJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(a.Signature[:], signature) + + if attestationJSON.CommitteeBits == "" { + return errors.New("committee bits missing") + } + + if a.CommitteeBits, err = hex.DecodeString(strings.TrimPrefix(attestationJSON.CommitteeBits, "0x")); err != nil { + return errors.Wrap(err, "invalid value for committee bits") + } + + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (a *Attestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&attestationYAML{ + AggregationBits: fmt.Sprintf("%#x", []byte(a.AggregationBits)), + Data: a.Data, + Signature: fmt.Sprintf("%#x", a.Signature), + CommitteeBits: fmt.Sprintf("%#x", []byte(a.CommitteeBits)), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *Attestation) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var attestationJSON attestationJSON + if err := yaml.Unmarshal(input, &attestationJSON); err != nil { + return err + } + + return a.unpack(&attestationJSON) +} + +// String returns a string version of the structure. +func (a *Attestation) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// CommitteeIndex returns the index if only one bit is set, otherwise error. +func (a *Attestation) CommitteeIndex() (phase0.CommitteeIndex, error) { + bits := a.CommitteeBits + if len(bits.BitIndices()) == 0 { + return 0, errors.New("no committee index found in committee bits") + } + + if len(bits.BitIndices()) > 1 { + return 0, errors.New("multiple committee indices found in committee bits") + } + + foundIndex := phase0.CommitteeIndex(bits.BitIndices()[0]) + + return foundIndex, nil +} + +// AggregateValidatorIndex returns the index if only one bit is set, otherwise error. +func (a *Attestation) AggregateValidatorIndex() (phase0.ValidatorIndex, error) { + bits := a.AggregationBits + if len(bits.BitIndices()) == 0 { + return 0, errors.New("no validator index found in aggregation bits") + } + + if len(bits.BitIndices()) > 1 { + return 0, errors.New("multiple validator indices found in aggregation bits") + } + + foundIndex := phase0.ValidatorIndex(bits.BitIndices()[0]) + + return foundIndex, nil +} + +// ToSingleAttestation returns a SingleAttestation representation of the Attestation. +func (a *Attestation) ToSingleAttestation(validatorIndex *phase0.ValidatorIndex) (*electra.SingleAttestation, error) { + if validatorIndex == nil { + return nil, errors.New("validator index is nil") + } + + committeeIndex, err := a.CommitteeIndex() + if err != nil { + return nil, err + } + + singleAttestation := electra.SingleAttestation{ + CommitteeIndex: committeeIndex, + AttesterIndex: *validatorIndex, + Data: a.Data, + Signature: a.Signature, + } + + return &singleAttestation, nil +} diff --git a/spec/gloas/attestation_ssz.go b/spec/gloas/attestation_ssz.go new file mode 100644 index 000000000..5833e1a52 --- /dev/null +++ b/spec/gloas/attestation_ssz.go @@ -0,0 +1,186 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: efa7422da7021e8ac7b4520b52e3e5bf651a342efab91cb7b683d5e6a7d2a7d8 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[Attestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *Attestation to SSZ-encoded bytes. +func (t *Attestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *Attestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *Attestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(Attestation) + } + dstlen := len(dst) + // Offset Field #0 'AggregationBits' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Static Field #3 'CommitteeBits' + vlen := len(t.CommitteeBits) + if vlen > 8 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8), "CommitteeBits") + } + dst = append(dst, t.CommitteeBits[:vlen]...) + if vlen < 8 { + dst = sszutils.AppendZeroPadding(dst, (8-vlen)*1) + } + } + { // Dynamic Field #0 'AggregationBits' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AggregationBits + vlen := len(t) + bval := []byte(t[:]) + if vlen == 0 { + bval = []byte{0x01} + } else if bval[vlen-1] == 0x00 { + return nil, sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + dst = append(dst, bval...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *Attestation from SSZ-encoded bytes. +func (t *Attestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 236 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 236) + } + // Field #0 'AggregationBits' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 236 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 236), "AggregationBits:o") + } + { // Field #1 'Data' (static) + buf := buf[4:132] + if t.Data == nil { + t.Data = new(phase0.AttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[132:228] + copy(t.Signature[:], buf) + } + { // Field #3 'CommitteeBits' (static) + buf := buf[228:236] + t.CommitteeBits = sszutils.ExpandSlice(t.CommitteeBits, 8) + copy(t.CommitteeBits[:], buf) + } + { // Field #0 'AggregationBits' (dynamic) + buf := buf[offset0:] + val1 := t.AggregationBits + blen := len(buf) + if blen == 0 || buf[blen-1] == 0x00 { + return sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + val1 = sszutils.ExpandSlice(val1, blen) + copy(val1[:], buf) + t.AggregationBits = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *Attestation. +func (t *Attestation) SizeSSZ() (size int) { + if t == nil { + t = new(Attestation) + } + // Field #0 'AggregationBits' offset (4 bytes) + // Field #1 'Data' static (128 bytes) + // Field #2 'Signature' static (96 bytes) + // Field #3 'CommitteeBits' static (8 bytes) + size += 236 + { // Dynamic field #0 'AggregationBits' + if len(t.AggregationBits) == 0 { + size += 1 + } else { + size += len(t.AggregationBits) + } + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *Attestation. +func (t *Attestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *Attestation using the given hash walker. +func (t *Attestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(Attestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregationBits' + t := t.AggregationBits + if l := len(t); l > 0 && t[l-1] == 0x00 { + return sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + idx := hh.StartTree(sszutils.TreeTypeNone) + bitlist, size := hasher.ParseProgressiveBitlistWithHasher(hh, t[:]) + hh.AppendBytes32(bitlist) + hh.MerkleizeProgressiveWithMixin(idx, size) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + { // Field #3 'CommitteeBits' + vlen := len(t.CommitteeBits) + if vlen > 8 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8), "CommitteeBits") + } + val := t.CommitteeBits[:] + if vlen < 8 { + val = sszutils.AppendZeroPadding(val, (8-vlen)*1) + } + hh.PutBytes(val[:8]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x0f}) + return nil +} diff --git a/spec/gloas/attestation_test.go b/spec/gloas/attestation_test.go new file mode 100644 index 000000000..4bbb9bf7c --- /dev/null +++ b/spec/gloas/attestation_test.go @@ -0,0 +1,192 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttestation_CommitteeIndex(t *testing.T) { + // Test cases + tests := []struct { + name string + expectedIndices []phase0.CommitteeIndex + errorMsg string + doNotSet bool + }{ + { + name: "Valid index 0", + expectedIndices: []phase0.CommitteeIndex{0}, + }, + { + name: "Valid index 4", + expectedIndices: []phase0.CommitteeIndex{4}, + }, + { + name: "Valid index 40", + expectedIndices: []phase0.CommitteeIndex{40}, + }, + { + name: "Invalid index 64", + expectedIndices: []phase0.CommitteeIndex{64}, + errorMsg: "no committee index found in committee bits", + }, + { + name: "Invalid no index set", + expectedIndices: []phase0.CommitteeIndex{4}, + errorMsg: "no committee index found in committee bits", + doNotSet: true, + }, + { + name: "Invalid multiple index set", + expectedIndices: []phase0.CommitteeIndex{4, 40}, + errorMsg: "multiple committee indices found in committee bits", + }, + } + // Run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + committeeBits := bitfield.NewBitvector64() + if !tt.doNotSet { + for _, expectedIndex := range tt.expectedIndices { + committeeBits.SetBitAt(uint64(expectedIndex), true) + } + } + attestation := &electra.Attestation{CommitteeBits: committeeBits} + + committeeIndex, err := attestation.CommitteeIndex() + if tt.errorMsg == "" { + require.NoError(t, err) + for _, expectedIndex := range tt.expectedIndices { + assert.Equal(t, expectedIndex, committeeIndex) + } + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + }) + } +} + +func TestAttestation_AggregateValidatorIndex(t *testing.T) { + // Test cases + tests := []struct { + name string + expectedIndices []phase0.ValidatorIndex + errorMsg string + doNotSet bool + }{ + { + name: "Valid index 0", + expectedIndices: []phase0.ValidatorIndex{0}, + }, + { + name: "Valid index 4", + expectedIndices: []phase0.ValidatorIndex{4}, + }, + { + name: "Valid index 140", + expectedIndices: []phase0.ValidatorIndex{140}, + }, + { + name: "Invalid index 160", + expectedIndices: []phase0.ValidatorIndex{160}, + errorMsg: "no validator index found in aggregation bits", + }, + { + name: "Invalid no index set", + expectedIndices: []phase0.ValidatorIndex{64}, + errorMsg: "no validator index found in aggregation bits", + doNotSet: true, + }, + { + name: "Invalid multiple index set", + expectedIndices: []phase0.ValidatorIndex{4, 40}, + errorMsg: "multiple validator indices found in aggregation bits", + }, + } + // Run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aggregateBits := bitfield.NewBitlist(160) + if !tt.doNotSet { + for _, expectedIndex := range tt.expectedIndices { + aggregateBits.SetBitAt(uint64(expectedIndex), true) + } + } + attestation := &electra.Attestation{AggregationBits: aggregateBits} + + aggregateValidatorIndex, err := attestation.AggregateValidatorIndex() + if tt.errorMsg == "" { + require.NoError(t, err) + for _, expectedIndex := range tt.expectedIndices { + assert.Equal(t, expectedIndex, aggregateValidatorIndex) + } + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + }) + } +} + +func TestAttestation_SSZ(t *testing.T) { + aggregateSize := uint64(131072) + aggregateBits := bitfield.NewBitlist(aggregateSize) + committeeSize := uint64(64) + committeeBits := bitfield.NewBitvector64() + attestation := electra.Attestation{ + AggregationBits: aggregateBits, + CommitteeBits: committeeBits, + Data: &phase0.AttestationData{ + Slot: 1, + Index: 1, + BeaconBlockRoot: phase0.Root{}, + Source: &phase0.Checkpoint{ + Epoch: 1, + Root: phase0.Root{}, + }, + Target: &phase0.Checkpoint{ + Epoch: 2, + Root: phase0.Root{}, + }, + }, + } + // Set a bit beyond the bit list. + aggregateBits.SetBitAt(aggregateSize, true) + // Set a bit in the last bit of the list. + aggregateBits.SetBitAt(aggregateSize-1, true) + + // Should only have the bit that was set on the last bit. + require.Equal(t, 1, len(aggregateBits.BitIndices())) + require.Equal(t, aggregateSize, aggregateBits.Len()) + + // Set a bit beyond the bit vector. + committeeBits.SetBitAt(committeeSize, true) + // Set a bit in the last bit of the vector. + committeeBits.SetBitAt(committeeSize-1, true) + + // Should only have the bit that was set on the last bit. + require.Equal(t, 1, len(committeeBits.BitIndices())) + + // Ensure we can actually serialise. + _, err := attestation.MarshalSSZ() + require.NoError(t, err) +} diff --git a/spec/gloas/attesterslashing.go b/spec/gloas/attesterslashing.go new file mode 100644 index 000000000..141d8ed54 --- /dev/null +++ b/spec/gloas/attesterslashing.go @@ -0,0 +1,107 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// AttesterSlashing provides information about an attester slashing. +type AttesterSlashing struct { + Attestation1 *IndexedAttestation + Attestation2 *IndexedAttestation +} + +// attesterSlashingJSON is the spec representation of the struct. +type attesterSlashingJSON struct { + Attestation1 *IndexedAttestation `json:"attestation_1"` + Attestation2 *IndexedAttestation `json:"attestation_2"` +} + +// attesterSlashingYAML is the spec representation of the struct. +type attesterSlashingYAML struct { + Attestation1 *IndexedAttestation `yaml:"attestation_1"` + Attestation2 *IndexedAttestation `yaml:"attestation_2"` +} + +// MarshalJSON implements json.Marshaler. +func (a *AttesterSlashing) MarshalJSON() ([]byte, error) { + return json.Marshal(&attesterSlashingJSON{ + Attestation1: a.Attestation1, + Attestation2: a.Attestation2, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *AttesterSlashing) UnmarshalJSON(input []byte) error { + var attesterSlashingJSON attesterSlashingJSON + if err := json.Unmarshal(input, &attesterSlashingJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&attesterSlashingJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (a *AttesterSlashing) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&attesterSlashingYAML{ + Attestation1: a.Attestation1, + Attestation2: a.Attestation2, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *AttesterSlashing) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var attesterSlashingJSON attesterSlashingJSON + if err := yaml.Unmarshal(input, &attesterSlashingJSON); err != nil { + return err + } + + return a.unpack(&attesterSlashingJSON) +} + +func (a *AttesterSlashing) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (a *AttesterSlashing) unpack(attesterSlashingJSON *attesterSlashingJSON) error { + if attesterSlashingJSON.Attestation1 == nil { + return errors.New("attestation 1 missing") + } + + a.Attestation1 = attesterSlashingJSON.Attestation1 + if attesterSlashingJSON.Attestation2 == nil { + return errors.New("attestation 2 missing") + } + + a.Attestation2 = attesterSlashingJSON.Attestation2 + + return nil +} diff --git a/spec/gloas/attesterslashing_ssz.go b/spec/gloas/attesterslashing_ssz.go new file mode 100644 index 000000000..875f42097 --- /dev/null +++ b/spec/gloas/attesterslashing_ssz.go @@ -0,0 +1,148 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 834d4b32b565ed8ddf561b91ac0c479c93bd2aad55733cf5dd6787d254e95231 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[AttesterSlashing](`ssz-static:"false"`) + +// MarshalSSZ marshals the *AttesterSlashing to SSZ-encoded bytes. +func (t *AttesterSlashing) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *AttesterSlashing to SSZ-encoded bytes, appending to the provided buffer. +func (t *AttesterSlashing) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(AttesterSlashing) + } + dstlen := len(dst) + // Offset Field #0 'Attestation1' + // Offset Field #1 'Attestation2' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Dynamic Field #0 'Attestation1' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Attestation1 + if t == nil { + t = new(IndexedAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Dynamic Field #1 'Attestation2' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.Attestation2 + if t == nil { + t = new(IndexedAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Attestation2") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *AttesterSlashing from SSZ-encoded bytes. +func (t *AttesterSlashing) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 8 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 8) + } + // Field #0 'Attestation1' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 8 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 8), "Attestation1:o") + } + // Field #1 'Attestation2' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Attestation2:o") + } + { // Field #0 'Attestation1' (dynamic) + buf := buf[offset0:offset1] + if t.Attestation1 == nil { + t.Attestation1 = new(IndexedAttestation) + } + if err = t.Attestation1.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Field #1 'Attestation2' (dynamic) + buf := buf[offset1:] + if t.Attestation2 == nil { + t.Attestation2 = new(IndexedAttestation) + } + if err = t.Attestation2.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Attestation2") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *AttesterSlashing. +func (t *AttesterSlashing) SizeSSZ() (size int) { + if t == nil { + t = new(AttesterSlashing) + } + // Field #0 'Attestation1' offset (4 bytes) + // Field #1 'Attestation2' offset (4 bytes) + size += 8 + { // Dynamic field #0 'Attestation1' + size += t.Attestation1.SizeSSZ() + } + { // Dynamic field #1 'Attestation2' + size += t.Attestation2.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *AttesterSlashing. +func (t *AttesterSlashing) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *AttesterSlashing using the given hash walker. +func (t *AttesterSlashing) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(AttesterSlashing) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Attestation1' + t := t.Attestation1 + if t == nil { + t = new(IndexedAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Field #1 'Attestation2' + t := t.Attestation2 + if t == nil { + t = new(IndexedAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Attestation2") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/beaconblock.go b/spec/gloas/beaconblock.go new file mode 100644 index 000000000..97042d5ee --- /dev/null +++ b/spec/gloas/beaconblock.go @@ -0,0 +1,40 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconBlock represents a beacon block for EIP-7732. +type BeaconBlock struct { + Slot phase0.Slot + ProposerIndex phase0.ValidatorIndex + ParentRoot phase0.Root `ssz-size:"32"` + StateRoot phase0.Root `ssz-size:"32"` + Body *BeaconBlockBody +} + +// String returns a string version of the structure. +func (b *BeaconBlock) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconblock_json.go b/spec/gloas/beaconblock_json.go new file mode 100644 index 000000000..5b5ced550 --- /dev/null +++ b/spec/gloas/beaconblock_json.go @@ -0,0 +1,69 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconBlockJSON is the spec representation of the struct. +type beaconBlockJSON struct { + Slot string `json:"slot"` + ProposerIndex string `json:"proposer_index"` + ParentRoot phase0.Root `json:"parent_root"` + StateRoot phase0.Root `json:"state_root"` + Body *BeaconBlockBody `json:"body"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconBlock) MarshalJSON() ([]byte, error) { + return json.Marshal(&beaconBlockJSON{ + Slot: fmt.Sprintf("%d", b.Slot), + ProposerIndex: fmt.Sprintf("%d", b.ProposerIndex), + ParentRoot: b.ParentRoot, + StateRoot: b.StateRoot, + Body: b.Body, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BeaconBlock) UnmarshalJSON(input []byte) error { + var data beaconBlockJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + b.Slot = phase0.Slot(slot) + + proposerIndex, err := strconv.ParseUint(data.ProposerIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid proposer index") + } + b.ProposerIndex = phase0.ValidatorIndex(proposerIndex) + + b.ParentRoot = data.ParentRoot + b.StateRoot = data.StateRoot + b.Body = data.Body + + return nil +} diff --git a/spec/gloas/beaconblock_ssz.go b/spec/gloas/beaconblock_ssz.go new file mode 100644 index 000000000..eee024f20 --- /dev/null +++ b/spec/gloas/beaconblock_ssz.go @@ -0,0 +1,155 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 29be7500521c82f8e1a4b25df318e7b03bc07606db0ec1084404668206d8495f +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconBlock](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconBlock to SSZ-encoded bytes. +func (t *BeaconBlock) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconBlock to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BeaconBlock) + } + dstlen := len(dst) + { // Static Field #0 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #1 'ProposerIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposerIndex)) + } + { // Static Field #2 'ParentRoot' + dst = append(dst, t.ParentRoot[:32]...) + } + { // Static Field #3 'StateRoot' + dst = append(dst, t.StateRoot[:32]...) + } + // Offset Field #4 'Body' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #4 'Body' + binary.LittleEndian.PutUint32(dst[dstlen+80:], uint32(len(dst)-dstlen)) + t := t.Body + if t == nil { + t = new(BeaconBlockBody) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Body") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconBlock from SSZ-encoded bytes. +func (t *BeaconBlock) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 84 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 84) + } + { // Field #0 'Slot' (static) + buf := buf[0:8] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'ProposerIndex' (static) + buf := buf[8:16] + t.ProposerIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'ParentRoot' (static) + buf := buf[16:48] + copy(t.ParentRoot[:], buf) + } + { // Field #3 'StateRoot' (static) + buf := buf[48:80] + copy(t.StateRoot[:], buf) + } + // Field #4 'Body' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[80:84])) + if offset4 != 84 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset4, 84), "Body:o") + } + { // Field #4 'Body' (dynamic) + buf := buf[offset4:] + if t.Body == nil { + t.Body = new(BeaconBlockBody) + } + if err = t.Body.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Body") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconBlock. +func (t *BeaconBlock) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconBlock) + } + // Field #0 'Slot' static (8 bytes) + // Field #1 'ProposerIndex' static (8 bytes) + // Field #2 'ParentRoot' static (32 bytes) + // Field #3 'StateRoot' static (32 bytes) + // Field #4 'Body' offset (4 bytes) + size += 84 + { // Dynamic field #4 'Body' + size += t.Body.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconBlock. +func (t *BeaconBlock) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconBlock using the given hash walker. +func (t *BeaconBlock) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconBlock) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #1 'ProposerIndex' + hh.PutUint64(uint64(t.ProposerIndex)) + } + { // Field #2 'ParentRoot' + hh.PutBytes(t.ParentRoot[:32]) + } + { // Field #3 'StateRoot' + hh.PutBytes(t.StateRoot[:32]) + } + { // Field #4 'Body' + t := t.Body + if t == nil { + t = new(BeaconBlockBody) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Body") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/beaconblock_yaml.go b/spec/gloas/beaconblock_yaml.go new file mode 100644 index 000000000..0dadf51e0 --- /dev/null +++ b/spec/gloas/beaconblock_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconBlock) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconBlockJSON{ + Slot: fmt.Sprintf("%d", b.Slot), + ProposerIndex: fmt.Sprintf("%d", b.ProposerIndex), + ParentRoot: b.ParentRoot, + StateRoot: b.StateRoot, + Body: b.Body, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconBlock) UnmarshalYAML(input []byte) error { + var unmarshaled beaconBlockJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/beaconblockbody.go b/spec/gloas/beaconblockbody.go new file mode 100644 index 000000000..1897d1631 --- /dev/null +++ b/spec/gloas/beaconblockbody.go @@ -0,0 +1,50 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconBlockBody represents the body of a beacon block for EIP-7732. +type BeaconBlockBody struct { + RANDAOReveal phase0.BLSSignature `ssz-index:"0"` + ETH1Data *phase0.ETH1Data `ssz-index:"1"` + Graffiti [32]byte `ssz-index:"2"` + ProposerSlashings []*phase0.ProposerSlashing `ssz-index:"3" ssz-type:"progressive-list"` + AttesterSlashings []*AttesterSlashing `ssz-index:"4" ssz-type:"progressive-list"` + Attestations []*Attestation `ssz-index:"5" ssz-type:"progressive-list"` + Deposits []*phase0.Deposit `ssz-index:"6" ssz-type:"progressive-list"` + VoluntaryExits []*phase0.SignedVoluntaryExit `ssz-index:"7" ssz-type:"progressive-list"` + SyncAggregate *altair.SyncAggregate `ssz-index:"8"` + BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `ssz-index:"9" ssz-type:"progressive-list"` + SignedExecutionPayloadBid *SignedExecutionPayloadBid `ssz-index:"10"` + PayloadAttestations []*PayloadAttestation `ssz-index:"11" ssz-type:"progressive-list"` + ParentExecutionRequests *ExecutionRequests `ssz-index:"12"` +} + +// String returns a string version of the structure. +func (b *BeaconBlockBody) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconblockbody_json.go b/spec/gloas/beaconblockbody_json.go new file mode 100644 index 000000000..0af42007d --- /dev/null +++ b/spec/gloas/beaconblockbody_json.go @@ -0,0 +1,109 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconBlockBodyJSON is the spec representation of the struct. +type beaconBlockBodyJSON struct { + RANDAOReveal string `json:"randao_reveal"` + ETH1Data *phase0.ETH1Data `json:"eth1_data"` + Graffiti string `json:"graffiti"` + ProposerSlashings []*phase0.ProposerSlashing `json:"proposer_slashings"` + AttesterSlashings []*AttesterSlashing `json:"attester_slashings"` + Attestations []*Attestation `json:"attestations"` + Deposits []*phase0.Deposit `json:"deposits"` + VoluntaryExits []*phase0.SignedVoluntaryExit `json:"voluntary_exits"` + SyncAggregate *altair.SyncAggregate `json:"sync_aggregate"` + BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `json:"bls_to_execution_changes"` + SignedExecutionPayloadBid *SignedExecutionPayloadBid `json:"signed_execution_payload_bid"` + PayloadAttestations []*PayloadAttestation `json:"payload_attestations"` + ParentExecutionRequests *ExecutionRequests `json:"parent_execution_requests"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconBlockBody) MarshalJSON() ([]byte, error) { + return json.Marshal(&beaconBlockBodyJSON{ + RANDAOReveal: fmt.Sprintf("%#x", b.RANDAOReveal), + ETH1Data: b.ETH1Data, + Graffiti: fmt.Sprintf("%#x", b.Graffiti), + ProposerSlashings: b.ProposerSlashings, + AttesterSlashings: b.AttesterSlashings, + Attestations: b.Attestations, + Deposits: b.Deposits, + VoluntaryExits: b.VoluntaryExits, + SyncAggregate: b.SyncAggregate, + BLSToExecutionChanges: b.BLSToExecutionChanges, + SignedExecutionPayloadBid: b.SignedExecutionPayloadBid, + PayloadAttestations: b.PayloadAttestations, + ParentExecutionRequests: b.ParentExecutionRequests, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BeaconBlockBody) UnmarshalJSON(input []byte) error { + var data beaconBlockBodyJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // RANDAO reveal + if data.RANDAOReveal == "" { + return errors.New("randao reveal missing") + } + randaoReveal, err := hex.DecodeString(strings.TrimPrefix(data.RANDAOReveal, "0x")) + if err != nil { + return errors.Wrap(err, "invalid randao reveal") + } + copy(b.RANDAOReveal[:], randaoReveal) + + b.ETH1Data = data.ETH1Data + + // Graffiti + if data.Graffiti == "" { + return errors.New("graffiti missing") + } + graffiti, err := hex.DecodeString(strings.TrimPrefix(data.Graffiti, "0x")) + if err != nil { + return errors.Wrap(err, "invalid graffiti") + } + copy(b.Graffiti[:], graffiti) + + b.ProposerSlashings = data.ProposerSlashings + b.AttesterSlashings = data.AttesterSlashings + b.Attestations = data.Attestations + b.Deposits = data.Deposits + b.VoluntaryExits = data.VoluntaryExits + b.SyncAggregate = data.SyncAggregate + b.BLSToExecutionChanges = data.BLSToExecutionChanges + b.SignedExecutionPayloadBid = data.SignedExecutionPayloadBid + b.PayloadAttestations = data.PayloadAttestations + + if data.ParentExecutionRequests == nil { + return errors.New("parent execution requests missing") + } + b.ParentExecutionRequests = data.ParentExecutionRequests + + return nil +} diff --git a/spec/gloas/beaconblockbody_ssz.go b/spec/gloas/beaconblockbody_ssz.go new file mode 100644 index 000000000..fc50d85c1 --- /dev/null +++ b/spec/gloas/beaconblockbody_ssz.go @@ -0,0 +1,712 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: ff294cfb40110eeb1710be67170dfa5bc91aa532d7cc6ea215b5545f7e14fcac +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconBlockBody](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconBlockBody to SSZ-encoded bytes. +func (t *BeaconBlockBody) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconBlockBody to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconBlockBody) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BeaconBlockBody) + } + dstlen := len(dst) + { // Static Field #0 'RANDAOReveal' + dst = append(dst, t.RANDAOReveal[:96]...) + } + { // Static Field #1 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Static Field #2 'Graffiti' + dst = append(dst, t.Graffiti[:32]...) + } + // Offset Field #3 'ProposerSlashings' + // Offset Field #4 'AttesterSlashings' + // Offset Field #5 'Attestations' + // Offset Field #6 'Deposits' + // Offset Field #7 'VoluntaryExits' + dst = append(dst, zeroBytes[:20]...) + { // Static Field #8 'SyncAggregate' + t := t.SyncAggregate + if t == nil { + t = new(altair.SyncAggregate) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + // Offset Field #9 'BLSToExecutionChanges' + // Offset Field #10 'SignedExecutionPayloadBid' + // Offset Field #11 'PayloadAttestations' + // Offset Field #12 'ParentExecutionRequests' + dst = append(dst, zeroBytes[:16]...) + { // Dynamic Field #3 'ProposerSlashings' + binary.LittleEndian.PutUint32(dst[dstlen+200:], uint32(len(dst)-dstlen)) + t := t.ProposerSlashings + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.ProposerSlashing) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + } + } + { // Dynamic Field #4 'AttesterSlashings' + binary.LittleEndian.PutUint32(dst[dstlen+204:], uint32(len(dst)-dstlen)) + t := t.AttesterSlashings + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + if t == nil { + t = new(AttesterSlashing) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + } + } + { // Dynamic Field #5 'Attestations' + binary.LittleEndian.PutUint32(dst[dstlen+208:], uint32(len(dst)-dstlen)) + t := t.Attestations + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + if t == nil { + t = new(Attestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + } + } + { // Dynamic Field #6 'Deposits' + binary.LittleEndian.PutUint32(dst[dstlen+212:], uint32(len(dst)-dstlen)) + t := t.Deposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.Deposit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + } + { // Dynamic Field #7 'VoluntaryExits' + binary.LittleEndian.PutUint32(dst[dstlen+216:], uint32(len(dst)-dstlen)) + t := t.VoluntaryExits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.SignedVoluntaryExit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + } + } + { // Dynamic Field #9 'BLSToExecutionChanges' + binary.LittleEndian.PutUint32(dst[dstlen+380:], uint32(len(dst)-dstlen)) + t := t.BLSToExecutionChanges + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.SignedBLSToExecutionChange) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + } + } + { // Dynamic Field #10 'SignedExecutionPayloadBid' + binary.LittleEndian.PutUint32(dst[dstlen+384:], uint32(len(dst)-dstlen)) + t := t.SignedExecutionPayloadBid + if t == nil { + t = new(SignedExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Dynamic Field #11 'PayloadAttestations' + binary.LittleEndian.PutUint32(dst[dstlen+388:], uint32(len(dst)-dstlen)) + t := t.PayloadAttestations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(PayloadAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + } + } + { // Dynamic Field #12 'ParentExecutionRequests' + binary.LittleEndian.PutUint32(dst[dstlen+392:], uint32(len(dst)-dstlen)) + t := t.ParentExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconBlockBody from SSZ-encoded bytes. +func (t *BeaconBlockBody) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 396 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 396) + } + { // Field #0 'RANDAOReveal' (static) + buf := buf[0:96] + copy(t.RANDAOReveal[:], buf) + } + { // Field #1 'ETH1Data' (static) + buf := buf[96:168] + if t.ETH1Data == nil { + t.ETH1Data = new(phase0.ETH1Data) + } + if err = t.ETH1Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #2 'Graffiti' (static) + buf := buf[168:200] + copy(t.Graffiti[:], buf) + } + // Field #3 'ProposerSlashings' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[200:204])) + if offset3 != 396 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset3, 396), "ProposerSlashings:o") + } + // Field #4 'AttesterSlashings' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[204:208])) + if offset4 < offset3 || offset4 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset4, offset3, buflen), "AttesterSlashings:o") + } + // Field #5 'Attestations' (offset) + offset5 := int(binary.LittleEndian.Uint32(buf[208:212])) + if offset5 < offset4 || offset5 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset5, offset4, buflen), "Attestations:o") + } + // Field #6 'Deposits' (offset) + offset6 := int(binary.LittleEndian.Uint32(buf[212:216])) + if offset6 < offset5 || offset6 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset6, offset5, buflen), "Deposits:o") + } + // Field #7 'VoluntaryExits' (offset) + offset7 := int(binary.LittleEndian.Uint32(buf[216:220])) + if offset7 < offset6 || offset7 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset7, offset6, buflen), "VoluntaryExits:o") + } + { // Field #8 'SyncAggregate' (static) + buf := buf[220:380] + if t.SyncAggregate == nil { + t.SyncAggregate = new(altair.SyncAggregate) + } + if err = t.SyncAggregate.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + // Field #9 'BLSToExecutionChanges' (offset) + offset9 := int(binary.LittleEndian.Uint32(buf[380:384])) + if offset9 < offset7 || offset9 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset9, offset7, buflen), "BLSToExecutionChanges:o") + } + // Field #10 'SignedExecutionPayloadBid' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[384:388])) + if offset10 < offset9 || offset10 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset10, offset9, buflen), "SignedExecutionPayloadBid:o") + } + // Field #11 'PayloadAttestations' (offset) + offset11 := int(binary.LittleEndian.Uint32(buf[388:392])) + if offset11 < offset10 || offset11 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset11, offset10, buflen), "PayloadAttestations:o") + } + // Field #12 'ParentExecutionRequests' (offset) + offset12 := int(binary.LittleEndian.Uint32(buf[392:396])) + if offset12 < offset11 || offset12 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset12, offset11, buflen), "ParentExecutionRequests:o") + } + { // Field #3 'ProposerSlashings' (dynamic) + buf := buf[offset3:offset4] + val1 := t.ProposerSlashings + itemCount := len(buf) / 416 + if len(buf)%416 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 416), "ProposerSlashings") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + if val1[idx1] == nil { + val1[idx1] = new(phase0.ProposerSlashing) + } + buf := buf[416*idx1 : 416*(idx1+1)] + if err = val1[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + } + t.ProposerSlashings = val1 + } + { // Field #4 'AttesterSlashings' (dynamic) + buf := buf[offset4:offset5] + val2 := t.AttesterSlashings + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "AttesterSlashings") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "AttesterSlashings") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "AttesterSlashings[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val3 := val2[idx1] + if val3 == nil { + val3 = new(AttesterSlashing) + } + if err = val3.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + val2[idx1] = val3 + } + t.AttesterSlashings = val2 + } + { // Field #5 'Attestations' (dynamic) + buf := buf[offset5:offset6] + val4 := t.Attestations + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "Attestations") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "Attestations") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "Attestations[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val5 := val4[idx1] + if val5 == nil { + val5 = new(Attestation) + } + if err = val5.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + val4[idx1] = val5 + } + t.Attestations = val4 + } + { // Field #6 'Deposits' (dynamic) + buf := buf[offset6:offset7] + val6 := t.Deposits + itemCount := len(buf) / 1240 + if len(buf)%1240 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 1240), "Deposits") + } + val6 = sszutils.ExpandSlice(val6, itemCount) + for idx1 := range itemCount { + if val6[idx1] == nil { + val6[idx1] = new(phase0.Deposit) + } + buf := buf[1240*idx1 : 1240*(idx1+1)] + if err = val6[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + t.Deposits = val6 + } + { // Field #7 'VoluntaryExits' (dynamic) + buf := buf[offset7:offset9] + val7 := t.VoluntaryExits + itemCount := len(buf) / 112 + if len(buf)%112 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 112), "VoluntaryExits") + } + val7 = sszutils.ExpandSlice(val7, itemCount) + for idx1 := range itemCount { + if val7[idx1] == nil { + val7[idx1] = new(phase0.SignedVoluntaryExit) + } + buf := buf[112*idx1 : 112*(idx1+1)] + if err = val7[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + } + t.VoluntaryExits = val7 + } + { // Field #9 'BLSToExecutionChanges' (dynamic) + buf := buf[offset9:offset10] + val8 := t.BLSToExecutionChanges + itemCount := len(buf) / 172 + if len(buf)%172 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 172), "BLSToExecutionChanges") + } + val8 = sszutils.ExpandSlice(val8, itemCount) + for idx1 := range itemCount { + if val8[idx1] == nil { + val8[idx1] = new(capella.SignedBLSToExecutionChange) + } + buf := buf[172*idx1 : 172*(idx1+1)] + if err = val8[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + } + t.BLSToExecutionChanges = val8 + } + { // Field #10 'SignedExecutionPayloadBid' (dynamic) + buf := buf[offset10:offset11] + if t.SignedExecutionPayloadBid == nil { + t.SignedExecutionPayloadBid = new(SignedExecutionPayloadBid) + } + if err = t.SignedExecutionPayloadBid.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Field #11 'PayloadAttestations' (dynamic) + buf := buf[offset11:offset12] + val9 := t.PayloadAttestations + itemCount := len(buf) / 202 + if len(buf)%202 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 202), "PayloadAttestations") + } + val9 = sszutils.ExpandSlice(val9, itemCount) + for idx1 := range itemCount { + if val9[idx1] == nil { + val9[idx1] = new(PayloadAttestation) + } + buf := buf[202*idx1 : 202*(idx1+1)] + if err = val9[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + } + t.PayloadAttestations = val9 + } + { // Field #12 'ParentExecutionRequests' (dynamic) + buf := buf[offset12:] + if t.ParentExecutionRequests == nil { + t.ParentExecutionRequests = new(ExecutionRequests) + } + if err = t.ParentExecutionRequests.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconBlockBody. +func (t *BeaconBlockBody) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconBlockBody) + } + // Field #0 'RANDAOReveal' static (96 bytes) + // Field #1 'ETH1Data' static (72 bytes) + // Field #2 'Graffiti' static (32 bytes) + // Field #3 'ProposerSlashings' offset (4 bytes) + // Field #4 'AttesterSlashings' offset (4 bytes) + // Field #5 'Attestations' offset (4 bytes) + // Field #6 'Deposits' offset (4 bytes) + // Field #7 'VoluntaryExits' offset (4 bytes) + // Field #8 'SyncAggregate' static (160 bytes) + // Field #9 'BLSToExecutionChanges' offset (4 bytes) + // Field #10 'SignedExecutionPayloadBid' offset (4 bytes) + // Field #11 'PayloadAttestations' offset (4 bytes) + // Field #12 'ParentExecutionRequests' offset (4 bytes) + size += 396 + { // Dynamic field #3 'ProposerSlashings' + size += len(t.ProposerSlashings) * 416 + } + { // Dynamic field #4 'AttesterSlashings' + t := t.AttesterSlashings + vlen := len(t) + size += vlen * 4 // Offsets + for i1 := range vlen { + size += t[i1].SizeSSZ() + } + } + { // Dynamic field #5 'Attestations' + t := t.Attestations + vlen := len(t) + size += vlen * 4 // Offsets + for i2 := range vlen { + size += t[i2].SizeSSZ() + } + } + { // Dynamic field #6 'Deposits' + size += len(t.Deposits) * 1240 + } + { // Dynamic field #7 'VoluntaryExits' + size += len(t.VoluntaryExits) * 112 + } + { // Dynamic field #9 'BLSToExecutionChanges' + size += len(t.BLSToExecutionChanges) * 172 + } + { // Dynamic field #10 'SignedExecutionPayloadBid' + size += t.SignedExecutionPayloadBid.SizeSSZ() + } + { // Dynamic field #11 'PayloadAttestations' + size += len(t.PayloadAttestations) * 202 + } + { // Dynamic field #12 'ParentExecutionRequests' + size += t.ParentExecutionRequests.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconBlockBody. +func (t *BeaconBlockBody) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconBlockBody using the given hash walker. +func (t *BeaconBlockBody) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconBlockBody) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'RANDAOReveal' + hh.PutBytes(t.RANDAOReveal[:96]) + } + { // Field #1 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #2 'Graffiti' + hh.PutBytes(t.Graffiti[:32]) + } + { // Field #3 'ProposerSlashings' + t := t.ProposerSlashings + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.ProposerSlashing) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #4 'AttesterSlashings' + t := t.AttesterSlashings + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(AttesterSlashing) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #5 'Attestations' + t := t.Attestations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(Attestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #6 'Deposits' + t := t.Deposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.Deposit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #7 'VoluntaryExits' + t := t.VoluntaryExits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.SignedVoluntaryExit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #8 'SyncAggregate' + t := t.SyncAggregate + if t == nil { + t = new(altair.SyncAggregate) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + { // Field #9 'BLSToExecutionChanges' + t := t.BLSToExecutionChanges + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.SignedBLSToExecutionChange) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #10 'SignedExecutionPayloadBid' + t := t.SignedExecutionPayloadBid + if t == nil { + t = new(SignedExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Field #11 'PayloadAttestations' + t := t.PayloadAttestations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(PayloadAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #12 'ParentExecutionRequests' + t := t.ParentExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0x1f}) + return nil +} diff --git a/spec/gloas/beaconblockbody_yaml.go b/spec/gloas/beaconblockbody_yaml.go new file mode 100644 index 000000000..3044be073 --- /dev/null +++ b/spec/gloas/beaconblockbody_yaml.go @@ -0,0 +1,61 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconBlockBody) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconBlockBodyJSON{ + RANDAOReveal: fmt.Sprintf("%#x", b.RANDAOReveal), + ETH1Data: b.ETH1Data, + Graffiti: fmt.Sprintf("%#x", b.Graffiti), + ProposerSlashings: b.ProposerSlashings, + AttesterSlashings: b.AttesterSlashings, + Attestations: b.Attestations, + Deposits: b.Deposits, + VoluntaryExits: b.VoluntaryExits, + SyncAggregate: b.SyncAggregate, + BLSToExecutionChanges: b.BLSToExecutionChanges, + SignedExecutionPayloadBid: b.SignedExecutionPayloadBid, + PayloadAttestations: b.PayloadAttestations, + ParentExecutionRequests: b.ParentExecutionRequests, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconBlockBody) UnmarshalYAML(input []byte) error { + var data beaconBlockBodyJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/beaconstate.go b/spec/gloas/beaconstate.go new file mode 100644 index 000000000..1a93244df --- /dev/null +++ b/spec/gloas/beaconstate.go @@ -0,0 +1,85 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconState represents a beacon state for EIP-7732. +type BeaconState struct { + GenesisTime uint64 `ssz-index:"0"` + GenesisValidatorsRoot phase0.Root `ssz-index:"1" ssz-size:"32"` + Slot phase0.Slot `ssz-index:"2"` + Fork *phase0.Fork `ssz-index:"3"` + LatestBlockHeader *phase0.BeaconBlockHeader `ssz-index:"4"` + BlockRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"5" ssz-size:"8192,32"` + StateRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"6" ssz-size:"8192,32"` + HistoricalRoots []phase0.Root `ssz-index:"7" ssz-max:"16777216" ssz-size:"?,32"` + ETH1Data *phase0.ETH1Data `ssz-index:"8"` + ETH1DataVotes []*phase0.ETH1Data `dynssz-max:"EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH" ssz-index:"9" ssz-max:"2048"` + ETH1DepositIndex uint64 `ssz-index:"10"` + Validators []*phase0.Validator `ssz-index:"11" ssz-type:"progressive-list"` + Balances []phase0.Gwei `ssz-index:"12" ssz-type:"progressive-list"` + RANDAOMixes []phase0.Root `dynssz-size:"EPOCHS_PER_HISTORICAL_VECTOR,32" ssz-index:"13" ssz-size:"65536,32"` + Slashings []phase0.Gwei `dynssz-size:"EPOCHS_PER_SLASHINGS_VECTOR" ssz-index:"14" ssz-size:"8192"` + PreviousEpochParticipation []altair.ParticipationFlags `ssz-index:"15" ssz-type:"progressive-list"` + CurrentEpochParticipation []altair.ParticipationFlags `ssz-index:"16" ssz-type:"progressive-list"` + JustificationBits bitfield.Bitvector4 `ssz-index:"17" ssz-size:"1"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"18"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"19"` + FinalizedCheckpoint *phase0.Checkpoint `ssz-index:"20"` + InactivityScores []uint64 `ssz-index:"21" ssz-type:"progressive-list"` + CurrentSyncCommittee *altair.SyncCommittee `ssz-index:"22"` + NextSyncCommittee *altair.SyncCommittee `ssz-index:"23"` + LatestBlockHash phase0.Hash32 `ssz-index:"24" ssz-size:"32"` + NextWithdrawalIndex capella.WithdrawalIndex `ssz-index:"25"` + NextWithdrawalValidatorIndex phase0.ValidatorIndex `ssz-index:"26"` + HistoricalSummaries []*capella.HistoricalSummary `ssz-index:"27" ssz-max:"16777216"` + DepositRequestsStartIndex uint64 `ssz-index:"28"` + DepositBalanceToConsume phase0.Gwei `ssz-index:"29"` + ExitBalanceToConsume phase0.Gwei `ssz-index:"30"` + EarliestExitEpoch phase0.Epoch `ssz-index:"31"` + ConsolidationBalanceToConsume phase0.Gwei `ssz-index:"32"` + EarliestConsolidationEpoch phase0.Epoch `ssz-index:"33"` + PendingDeposits []*electra.PendingDeposit `ssz-index:"34" ssz-type:"progressive-list"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `ssz-index:"35" ssz-type:"progressive-list"` + PendingConsolidations []*electra.PendingConsolidation `ssz-index:"36" ssz-type:"progressive-list"` + ProposerLookahead []phase0.ValidatorIndex `dynssz-size:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH" ssz-index:"37" ssz-size:"64"` + Builders []*Builder `ssz-index:"38" ssz-type:"progressive-list"` + NextWithdrawalBuilderIndex BuilderIndex `ssz-index:"39"` + ExecutionPayloadAvailability []uint8 `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT/8" ssz-index:"40" ssz-size:"1024"` + BuilderPendingPayments []*BuilderPendingPayment `dynssz-size:"SLOTS_PER_EPOCH*2" ssz-index:"41" ssz-size:"64"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `ssz-index:"42" ssz-type:"progressive-list"` + LatestExecutionPayloadBid *ExecutionPayloadBid `ssz-index:"43"` + PayloadExpectedWithdrawals []*capella.Withdrawal `ssz-index:"44" ssz-type:"progressive-list"` + PTCWindow [][]phase0.ValidatorIndex `dynssz-size:"(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH,PTC_SIZE" ssz-index:"45" ssz-size:"96,512"` +} + +// String returns a string version of the structure. +func (b *BeaconState) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go new file mode 100644 index 000000000..9719edf8c --- /dev/null +++ b/spec/gloas/beaconstate_json.go @@ -0,0 +1,461 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconStateJSON is the spec representation of the struct. +type beaconStateJSON struct { + GenesisTime string `json:"genesis_time"` + GenesisValidatorsRoot phase0.Root `json:"genesis_validators_root"` + Slot phase0.Slot `json:"slot"` + Fork *phase0.Fork `json:"fork"` + LatestBlockHeader *phase0.BeaconBlockHeader `json:"latest_block_header"` + BlockRoots []phase0.Root `json:"block_roots"` + StateRoots []phase0.Root `json:"state_roots"` + HistoricalRoots []phase0.Root `json:"historical_roots"` + ETH1Data *phase0.ETH1Data `json:"eth1_data"` + //nolint:staticcheck + ETH1DataVotes []*phase0.ETH1Data `json:"eth1_data_votes,allowempty"` + ETH1DepositIndex string `json:"eth1_deposit_index"` + Validators []*phase0.Validator `json:"validators"` + Balances []string `json:"balances"` + RANDAOMixes []string `json:"randao_mixes"` + Slashings []string `json:"slashings"` + PreviousEpochParticipation []string `json:"previous_epoch_participation"` + CurrentEpochParticipation []string `json:"current_epoch_participation"` + JustificationBits string `json:"justification_bits"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `json:"previous_justified_checkpoint"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `json:"current_justified_checkpoint"` + FinalizedCheckpoint *phase0.Checkpoint `json:"finalized_checkpoint"` + InactivityScores []string `json:"inactivity_scores"` + CurrentSyncCommittee *altair.SyncCommittee `json:"current_sync_committee"` + NextSyncCommittee *altair.SyncCommittee `json:"next_sync_committee"` + LatestBlockHash string `json:"latest_block_hash"` + NextWithdrawalIndex string `json:"next_withdrawal_index"` + NextWithdrawalValidatorIndex string `json:"next_withdrawal_validator_index"` + HistoricalSummaries []*capella.HistoricalSummary `json:"historical_summaries"` + DepositRequestsStartIndex string `json:"deposit_requests_start_index"` + DepositBalanceToConsume phase0.Gwei `json:"deposit_balance_to_consume"` + ExitBalanceToConsume phase0.Gwei `json:"exit_balance_to_consume"` + EarliestExitEpoch phase0.Epoch `json:"earliest_exit_epoch"` + ConsolidationBalanceToConsume phase0.Gwei `json:"consolidation_balance_to_consume"` + EarliestConsolidationEpoch phase0.Epoch `json:"earliest_consolidation_epoch"` + PendingDeposits []*electra.PendingDeposit `json:"pending_deposits"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `json:"pending_partial_withdrawals"` + PendingConsolidations []*electra.PendingConsolidation `json:"pending_consolidations"` + ProposerLookahead []string `json:"proposer_lookahead"` + Builders []*Builder `json:"builders"` + NextWithdrawalBuilderIndex string `json:"next_withdrawal_builder_index"` + ExecutionPayloadAvailability []string `json:"execution_payload_availability"` + BuilderPendingPayments []*BuilderPendingPayment `json:"builder_pending_payments"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `json:"builder_pending_withdrawals"` + LatestExecutionPayloadBid *ExecutionPayloadBid `json:"latest_execution_payload_bid"` + PayloadExpectedWithdrawals []*capella.Withdrawal `json:"payload_expected_withdrawals"` + PTCWindow [][]string `json:"ptc_window"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconState) MarshalJSON() ([]byte, error) { + balances := make([]string, len(b.Balances)) + for i := range b.Balances { + balances[i] = fmt.Sprintf("%d", b.Balances[i]) + } + randaoMixes := make([]string, len(b.RANDAOMixes)) + for i := range b.RANDAOMixes { + randaoMixes[i] = fmt.Sprintf("%#x", b.RANDAOMixes[i]) + } + slashings := make([]string, len(b.Slashings)) + for i := range b.Slashings { + slashings[i] = fmt.Sprintf("%d", b.Slashings[i]) + } + previousEpochParticipation := make([]string, len(b.PreviousEpochParticipation)) + for i := range b.PreviousEpochParticipation { + previousEpochParticipation[i] = fmt.Sprintf("%d", b.PreviousEpochParticipation[i]) + } + currentEpochParticipation := make([]string, len(b.CurrentEpochParticipation)) + for i := range b.CurrentEpochParticipation { + currentEpochParticipation[i] = fmt.Sprintf("%d", b.CurrentEpochParticipation[i]) + } + inactivityScores := make([]string, len(b.InactivityScores)) + for i := range b.InactivityScores { + inactivityScores[i] = strconv.FormatUint(b.InactivityScores[i], 10) + } + proposerLookahead := make([]string, len(b.ProposerLookahead)) + for i := range b.ProposerLookahead { + proposerLookahead[i] = fmt.Sprintf("%d", b.ProposerLookahead[i]) + } + executionPayloadAvailability := make([]string, len(b.ExecutionPayloadAvailability)) + for i := range b.ExecutionPayloadAvailability { + executionPayloadAvailability[i] = fmt.Sprintf("%d", b.ExecutionPayloadAvailability[i]) + } + ptcWindow := make([][]string, len(b.PTCWindow)) + for i := range b.PTCWindow { + ptcWindow[i] = make([]string, len(b.PTCWindow[i])) + for j := range b.PTCWindow[i] { + ptcWindow[i][j] = fmt.Sprintf("%d", b.PTCWindow[i][j]) + } + } + + return json.Marshal(&beaconStateJSON{ + GenesisTime: strconv.FormatUint(b.GenesisTime, 10), + GenesisValidatorsRoot: b.GenesisValidatorsRoot, + Slot: b.Slot, + Fork: b.Fork, + LatestBlockHeader: b.LatestBlockHeader, + BlockRoots: b.BlockRoots, + StateRoots: b.StateRoots, + HistoricalRoots: b.HistoricalRoots, + ETH1Data: b.ETH1Data, + ETH1DataVotes: b.ETH1DataVotes, + ETH1DepositIndex: strconv.FormatUint(b.ETH1DepositIndex, 10), + Validators: b.Validators, + Balances: balances, + RANDAOMixes: randaoMixes, + Slashings: slashings, + PreviousEpochParticipation: previousEpochParticipation, + CurrentEpochParticipation: currentEpochParticipation, + JustificationBits: fmt.Sprintf("%#x", b.JustificationBits.Bytes()), + PreviousJustifiedCheckpoint: b.PreviousJustifiedCheckpoint, + CurrentJustifiedCheckpoint: b.CurrentJustifiedCheckpoint, + FinalizedCheckpoint: b.FinalizedCheckpoint, + InactivityScores: inactivityScores, + CurrentSyncCommittee: b.CurrentSyncCommittee, + NextSyncCommittee: b.NextSyncCommittee, + LatestBlockHash: fmt.Sprintf("%#x", b.LatestBlockHash), + NextWithdrawalIndex: fmt.Sprintf("%d", b.NextWithdrawalIndex), + NextWithdrawalValidatorIndex: fmt.Sprintf("%d", b.NextWithdrawalValidatorIndex), + HistoricalSummaries: b.HistoricalSummaries, + DepositRequestsStartIndex: fmt.Sprintf("%d", b.DepositRequestsStartIndex), + DepositBalanceToConsume: b.DepositBalanceToConsume, + ExitBalanceToConsume: b.ExitBalanceToConsume, + EarliestExitEpoch: b.EarliestExitEpoch, + ConsolidationBalanceToConsume: b.ConsolidationBalanceToConsume, + EarliestConsolidationEpoch: b.EarliestConsolidationEpoch, + PendingDeposits: b.PendingDeposits, + PendingPartialWithdrawals: b.PendingPartialWithdrawals, + PendingConsolidations: b.PendingConsolidations, + ProposerLookahead: proposerLookahead, + Builders: b.Builders, + NextWithdrawalBuilderIndex: fmt.Sprintf("%d", b.NextWithdrawalBuilderIndex), + ExecutionPayloadAvailability: executionPayloadAvailability, + BuilderPendingPayments: b.BuilderPendingPayments, + BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, + LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, + PayloadExpectedWithdrawals: b.PayloadExpectedWithdrawals, + PTCWindow: ptcWindow, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo +func (b *BeaconState) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&beaconStateJSON{}, input) + if err != nil { + return err + } + + genesisTime := string(bytes.Trim(raw["genesis_time"], `"`)) + if b.GenesisTime, err = strconv.ParseUint(genesisTime, 10, 64); err != nil { + return errors.Wrap(err, "genesis_time") + } + + if err := b.GenesisValidatorsRoot.UnmarshalJSON(raw["genesis_validators_root"]); err != nil { + return errors.Wrap(err, "genesis_validators_root") + } + + if err := b.Slot.UnmarshalJSON(raw["slot"]); err != nil { + return errors.Wrap(err, "slot") + } + + b.Fork = &phase0.Fork{} + if err := b.Fork.UnmarshalJSON(raw["fork"]); err != nil { + return errors.Wrap(err, "fork") + } + + b.LatestBlockHeader = &phase0.BeaconBlockHeader{} + if err := b.LatestBlockHeader.UnmarshalJSON(raw["latest_block_header"]); err != nil { + return errors.Wrap(err, "latest_block_header") + } + + if err := json.Unmarshal(raw["block_roots"], &b.BlockRoots); err != nil { + return errors.Wrap(err, "block_roots") + } + + if err := json.Unmarshal(raw["state_roots"], &b.StateRoots); err != nil { + return errors.Wrap(err, "state_roots") + } + + if err := json.Unmarshal(raw["historical_roots"], &b.HistoricalRoots); err != nil { + return errors.Wrap(err, "historical_roots") + } + + b.ETH1Data = &phase0.ETH1Data{} + if err := b.ETH1Data.UnmarshalJSON(raw["eth1_data"]); err != nil { + return errors.Wrap(err, "eth1_data") + } + + if err := json.Unmarshal(raw["eth1_data_votes"], &b.ETH1DataVotes); err != nil { + return errors.Wrap(err, "eth1_data_votes") + } + for i := range b.ETH1DataVotes { + if b.ETH1DataVotes[i] == nil { + return fmt.Errorf("eth1 data votes entry %d missing", i) + } + } + + eth1DepositIndex := string(bytes.Trim(raw["eth1_deposit_index"], `"`)) + if b.ETH1DepositIndex, err = strconv.ParseUint(eth1DepositIndex, 10, 64); err != nil { + return errors.Wrap(err, "eth1_deposit_index") + } + + if err := json.Unmarshal(raw["validators"], &b.Validators); err != nil { + return errors.Wrap(err, "validators") + } + for i := range b.Validators { + if b.Validators[i] == nil { + return fmt.Errorf("validators entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["balances"], &b.Balances); err != nil { + return errors.Wrap(err, "balances") + } + + if err := json.Unmarshal(raw["randao_mixes"], &b.RANDAOMixes); err != nil { + return errors.Wrap(err, "randao_mixes") + } + + if err := json.Unmarshal(raw["slashings"], &b.Slashings); err != nil { + return errors.Wrap(err, "slashings") + } + + if err := json.Unmarshal(raw["previous_epoch_participation"], &b.PreviousEpochParticipation); err != nil { + return errors.Wrap(err, "previous_epoch_participation") + } + + if err := json.Unmarshal(raw["current_epoch_participation"], &b.CurrentEpochParticipation); err != nil { + return errors.Wrap(err, "current_epoch_participation") + } + + justificationBits := string(bytes.TrimPrefix(bytes.Trim(raw["justification_bits"], `"`), []byte{'0', 'x'})) + if b.JustificationBits, err = hex.DecodeString(justificationBits); err != nil { + return errors.Wrap(err, "justification_bits") + } + + b.PreviousJustifiedCheckpoint = &phase0.Checkpoint{} + if err := b.PreviousJustifiedCheckpoint.UnmarshalJSON(raw["previous_justified_checkpoint"]); err != nil { + return errors.Wrap(err, "previous_justified_checkpoint") + } + + b.CurrentJustifiedCheckpoint = &phase0.Checkpoint{} + if err := b.CurrentJustifiedCheckpoint.UnmarshalJSON(raw["current_justified_checkpoint"]); err != nil { + return errors.Wrap(err, "current_justified_checkpoint") + } + + b.FinalizedCheckpoint = &phase0.Checkpoint{} + if err := b.FinalizedCheckpoint.UnmarshalJSON(raw["finalized_checkpoint"]); err != nil { + return errors.Wrap(err, "finalized_checkpoint") + } + + inactivityScores := make([]string, 0) + if err := json.Unmarshal(raw["inactivity_scores"], &inactivityScores); err != nil { + return errors.Wrap(err, "inactivity_scores") + } + b.InactivityScores = make([]uint64, len(inactivityScores)) + for i := range inactivityScores { + if inactivityScores[i] == "" { + return fmt.Errorf("inactivity score %d missing", i) + } + if b.InactivityScores[i], err = strconv.ParseUint(inactivityScores[i], 10, 64); err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for inactivity score %d", i)) + } + } + + b.CurrentSyncCommittee = &altair.SyncCommittee{} + if err := b.CurrentSyncCommittee.UnmarshalJSON(raw["current_sync_committee"]); err != nil { + return errors.Wrap(err, "current_sync_committee") + } + + b.NextSyncCommittee = &altair.SyncCommittee{} + if err := b.NextSyncCommittee.UnmarshalJSON(raw["next_sync_committee"]); err != nil { + return errors.Wrap(err, "next_sync_committee") + } + + if raw["latest_block_hash"] == nil { + return errors.New("latest block hash missing") + } + latestBlockHash, err := hex.DecodeString( + strings.TrimPrefix(string(bytes.Trim(raw["latest_block_hash"], `"`)), "0x"), + ) + if err != nil { + return errors.Wrap(err, "invalid latest block hash") + } + copy(b.LatestBlockHash[:], latestBlockHash) + + if err := b.NextWithdrawalIndex.UnmarshalJSON(raw["next_withdrawal_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_index") + } + + if err := b.NextWithdrawalValidatorIndex.UnmarshalJSON(raw["next_withdrawal_validator_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_validator_index") + } + + if err := json.Unmarshal(raw["historical_summaries"], &b.HistoricalSummaries); err != nil { + return errors.Wrap(err, "historical_summaries") + } + for i := range b.HistoricalSummaries { + if b.HistoricalSummaries[i] == nil { + return fmt.Errorf("historical summaries entry %d missing", i) + } + } + + depositRequestsStartIndex := string(bytes.Trim(raw["deposit_requests_start_index"], `"`)) + if b.DepositRequestsStartIndex, err = strconv.ParseUint(depositRequestsStartIndex, 10, 64); err != nil { + return errors.Wrap(err, "deposit_requests_start_index") + } + + if err := b.DepositBalanceToConsume.UnmarshalJSON(raw["deposit_balance_to_consume"]); err != nil { + return errors.Wrap(err, "deposit_balance_to_consume") + } + + if err := b.ExitBalanceToConsume.UnmarshalJSON(raw["exit_balance_to_consume"]); err != nil { + return errors.Wrap(err, "exit_balance_to_consume") + } + + if err := b.EarliestExitEpoch.UnmarshalJSON(raw["earliest_exit_epoch"]); err != nil { + return errors.Wrap(err, "earliest_exit_epoch") + } + + if err := b.ConsolidationBalanceToConsume.UnmarshalJSON(raw["consolidation_balance_to_consume"]); err != nil { + return errors.Wrap(err, "consolidation_balance_to_consume") + } + + if err := b.EarliestConsolidationEpoch.UnmarshalJSON(raw["earliest_consolidation_epoch"]); err != nil { + return errors.Wrap(err, "earliest_consolidation_epoch") + } + + if err := json.Unmarshal(raw["pending_deposits"], &b.PendingDeposits); err != nil { + return errors.Wrap(err, "pending_deposits") + } + for i := range b.PendingDeposits { + if b.PendingDeposits[i] == nil { + return fmt.Errorf("pending deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["pending_partial_withdrawals"], &b.PendingPartialWithdrawals); err != nil { + return errors.Wrap(err, "pending_partial_withdrawals") + } + for i := range b.PendingPartialWithdrawals { + if b.PendingPartialWithdrawals[i] == nil { + return fmt.Errorf("pending partial withdrawals entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["pending_consolidations"], &b.PendingConsolidations); err != nil { + return errors.Wrap(err, "pending_consolidations") + } + for i := range b.PendingConsolidations { + if b.PendingConsolidations[i] == nil { + return fmt.Errorf("pending consolidations entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["proposer_lookahead"], &b.ProposerLookahead); err != nil { + return errors.Wrap(err, "proposer_lookahead") + } + + if err := json.Unmarshal(raw["builders"], &b.Builders); err != nil { + return errors.Wrap(err, "builders") + } + for i := range b.Builders { + if b.Builders[i] == nil { + return fmt.Errorf("builders entry %d missing", i) + } + } + + if err := b.NextWithdrawalBuilderIndex.UnmarshalJSON(raw["next_withdrawal_builder_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_builder_index") + } + + if err := json.Unmarshal(raw["execution_payload_availability"], &b.ExecutionPayloadAvailability); err != nil { + return errors.Wrap(err, "execution_payload_availability") + } + + if err := json.Unmarshal(raw["builder_pending_payments"], &b.BuilderPendingPayments); err != nil { + return errors.Wrap(err, "builder_pending_payments") + } + for i := range b.BuilderPendingPayments { + if b.BuilderPendingPayments[i] == nil { + return fmt.Errorf("builder pending payments entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_pending_withdrawals"], &b.BuilderPendingWithdrawals); err != nil { + return errors.Wrap(err, "builder_pending_withdrawals") + } + for i := range b.BuilderPendingWithdrawals { + if b.BuilderPendingWithdrawals[i] == nil { + return fmt.Errorf("builder pending withdrawals entry %d missing", i) + } + } + + b.LatestExecutionPayloadBid = &ExecutionPayloadBid{} + if err := b.LatestExecutionPayloadBid.UnmarshalJSON(raw["latest_execution_payload_bid"]); err != nil { + return errors.Wrap(err, "latest_execution_payload_bid") + } + + if err := json.Unmarshal(raw["payload_expected_withdrawals"], &b.PayloadExpectedWithdrawals); err != nil { + return errors.Wrap(err, "payload_expected_withdrawals") + } + for i := range b.PayloadExpectedWithdrawals { + if b.PayloadExpectedWithdrawals[i] == nil { + return fmt.Errorf("payload expected withdrawals entry %d missing", i) + } + } + + ptcWindowStr := make([][]string, 0) + if err := json.Unmarshal(raw["ptc_window"], &ptcWindowStr); err != nil { + return errors.Wrap(err, "ptc_window") + } + b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowStr)) + for i := range ptcWindowStr { + b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowStr[i])) + for j := range ptcWindowStr[i] { + idx, parseErr := strconv.ParseUint(ptcWindowStr[i][j], 10, 64) + if parseErr != nil { + return errors.Wrap(parseErr, fmt.Sprintf("ptc_window[%d][%d]", i, j)) + } + b.PTCWindow[i][j] = phase0.ValidatorIndex(idx) + } + } + + return nil +} diff --git a/spec/gloas/beaconstate_ssz.go b/spec/gloas/beaconstate_ssz.go new file mode 100644 index 000000000..11b6a3de1 --- /dev/null +++ b/spec/gloas/beaconstate_ssz.go @@ -0,0 +1,1661 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: ca49eeff1eb3a573171246c7a7acf20b632c2d84e1c44038589f03a30b275b04 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconState](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconState to SSZ-encoded bytes. +func (t *BeaconState) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconState to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconState) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BeaconState) + } + dstlen := len(dst) + { // Static Field #0 'GenesisTime' + dst = binary.LittleEndian.AppendUint64(dst, t.GenesisTime) + } + { // Static Field #1 'GenesisValidatorsRoot' + dst = append(dst, t.GenesisValidatorsRoot[:32]...) + } + { // Static Field #2 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #3 'Fork' + t := t.Fork + if t == nil { + t = new(phase0.Fork) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Fork") + } + } + { // Static Field #4 'LatestBlockHeader' + t := t.LatestBlockHeader + if t == nil { + t = new(phase0.BeaconBlockHeader) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Static Field #5 'BlockRoots' + t := t.BlockRoots + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "BlockRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*32) + } + } + { // Static Field #6 'StateRoots' + t := t.StateRoots + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "StateRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*32) + } + } + // Offset Field #7 'HistoricalRoots' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #8 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ETH1Data") + } + } + // Offset Field #9 'ETH1DataVotes' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #10 'ETH1DepositIndex' + dst = binary.LittleEndian.AppendUint64(dst, t.ETH1DepositIndex) + } + // Offset Field #11 'Validators' + // Offset Field #12 'Balances' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #13 'RANDAOMixes' + t := t.RANDAOMixes + vlen := len(t) + if vlen > 65536 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 65536), "RANDAOMixes") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 65536 { + dst = sszutils.AppendZeroPadding(dst, (65536-vlen)*32) + } + } + { // Static Field #14 'Slashings' + t := t.Slashings + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "Slashings") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*8) + } + } + // Offset Field #15 'PreviousEpochParticipation' + // Offset Field #16 'CurrentEpochParticipation' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #17 'JustificationBits' + vlen := len(t.JustificationBits) + if vlen > 1 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1), "JustificationBits") + } + dst = append(dst, t.JustificationBits[:vlen]...) + if vlen < 1 { + dst = sszutils.AppendZeroPadding(dst, (1-vlen)*1) + } + } + { // Static Field #18 'PreviousJustifiedCheckpoint' + t := t.PreviousJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Static Field #19 'CurrentJustifiedCheckpoint' + t := t.CurrentJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Static Field #20 'FinalizedCheckpoint' + t := t.FinalizedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + // Offset Field #21 'InactivityScores' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #22 'CurrentSyncCommittee' + t := t.CurrentSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Static Field #23 'NextSyncCommittee' + t := t.NextSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Static Field #24 'LatestBlockHash' + dst = append(dst, t.LatestBlockHash[:32]...) + } + { // Static Field #25 'NextWithdrawalIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalIndex)) + } + { // Static Field #26 'NextWithdrawalValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalValidatorIndex)) + } + // Offset Field #27 'HistoricalSummaries' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #28 'DepositRequestsStartIndex' + dst = binary.LittleEndian.AppendUint64(dst, t.DepositRequestsStartIndex) + } + { // Static Field #29 'DepositBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.DepositBalanceToConsume)) + } + { // Static Field #30 'ExitBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ExitBalanceToConsume)) + } + { // Static Field #31 'EarliestExitEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.EarliestExitEpoch)) + } + { // Static Field #32 'ConsolidationBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ConsolidationBalanceToConsume)) + } + { // Static Field #33 'EarliestConsolidationEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.EarliestConsolidationEpoch)) + } + // Offset Field #34 'PendingDeposits' + // Offset Field #35 'PendingPartialWithdrawals' + // Offset Field #36 'PendingConsolidations' + dst = append(dst, zeroBytes[:12]...) + { // Static Field #37 'ProposerLookahead' + t := t.ProposerLookahead + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "ProposerLookahead") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*8) + } + } + // Offset Field #38 'Builders' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #39 'NextWithdrawalBuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalBuilderIndex)) + } + { // Static Field #40 'ExecutionPayloadAvailability' + vlen := len(t.ExecutionPayloadAvailability) + if vlen > 1024 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1024), "ExecutionPayloadAvailability") + } + dst = append(dst, t.ExecutionPayloadAvailability[:vlen]...) + if vlen < 1024 { + dst = sszutils.AppendZeroPadding(dst, (1024-vlen)*1) + } + } + { // Static Field #41 'BuilderPendingPayments' + t := t.BuilderPendingPayments + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "BuilderPendingPayments") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderPendingPayment) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + } + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*52) + } + } + // Offset Field #42 'BuilderPendingWithdrawals' + // Offset Field #43 'LatestExecutionPayloadBid' + // Offset Field #44 'PayloadExpectedWithdrawals' + dst = append(dst, zeroBytes[:12]...) + { // Static Field #45 'PTCWindow' + t := t.PTCWindow + vlen := len(t) + if vlen > 96 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 96), "PTCWindow") + } + for idx1 := range vlen { + t := t[idx1] + vlen := len(t) + if vlen > 512 { + return nil, sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, 512), "PTCWindow[%d]", idx1) + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 512 { + dst = sszutils.AppendZeroPadding(dst, (512-vlen)*8) + } + } + if vlen < 96 { + dst = sszutils.AppendZeroPadding(dst, (96-vlen)*4096) + } + } + { // Dynamic Field #7 'HistoricalRoots' + binary.LittleEndian.PutUint32(dst[dstlen+524464:], uint32(len(dst)-dstlen)) + t := t.HistoricalRoots + vlen := len(t) + if vlen > 16777216 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #9 'ETH1DataVotes' + binary.LittleEndian.PutUint32(dst[dstlen+524540:], uint32(len(dst)-dstlen)) + t := t.ETH1DataVotes + vlen := len(t) + if vlen > 2048 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "ETH1DataVotes") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + } + } + { // Dynamic Field #11 'Validators' + binary.LittleEndian.PutUint32(dst[dstlen+524552:], uint32(len(dst)-dstlen)) + t := t.Validators + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.Validator) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + } + } + { // Dynamic Field #12 'Balances' + binary.LittleEndian.PutUint32(dst[dstlen+524556:], uint32(len(dst)-dstlen)) + t := t.Balances + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + { // Dynamic Field #15 'PreviousEpochParticipation' + binary.LittleEndian.PutUint32(dst[dstlen+2687248:], uint32(len(dst)-dstlen)) + t := t.PreviousEpochParticipation + vlen := len(t) + for idx1 := range vlen { + dst = append(dst, byte(t[idx1])) + } + } + { // Dynamic Field #16 'CurrentEpochParticipation' + binary.LittleEndian.PutUint32(dst[dstlen+2687252:], uint32(len(dst)-dstlen)) + t := t.CurrentEpochParticipation + vlen := len(t) + for idx1 := range vlen { + dst = append(dst, byte(t[idx1])) + } + } + { // Dynamic Field #21 'InactivityScores' + binary.LittleEndian.PutUint32(dst[dstlen+2687377:], uint32(len(dst)-dstlen)) + t := t.InactivityScores + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + { // Dynamic Field #27 'HistoricalSummaries' + binary.LittleEndian.PutUint32(dst[dstlen+2736677:], uint32(len(dst)-dstlen)) + t := t.HistoricalSummaries + vlen := len(t) + if vlen > 16777216 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.HistoricalSummary) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + } + } + { // Dynamic Field #34 'PendingDeposits' + binary.LittleEndian.PutUint32(dst[dstlen+2736729:], uint32(len(dst)-dstlen)) + t := t.PendingDeposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingDeposit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + } + } + { // Dynamic Field #35 'PendingPartialWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2736733:], uint32(len(dst)-dstlen)) + t := t.PendingPartialWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingPartialWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #36 'PendingConsolidations' + binary.LittleEndian.PutUint32(dst[dstlen+2736737:], uint32(len(dst)-dstlen)) + t := t.PendingConsolidations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingConsolidation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + } + } + { // Dynamic Field #38 'Builders' + binary.LittleEndian.PutUint32(dst[dstlen+2737253:], uint32(len(dst)-dstlen)) + t := t.Builders + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(Builder) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + } + } + { // Dynamic Field #42 'BuilderPendingWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2741617:], uint32(len(dst)-dstlen)) + t := t.BuilderPendingWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #43 'LatestExecutionPayloadBid' + binary.LittleEndian.PutUint32(dst[dstlen+2741621:], uint32(len(dst)-dstlen)) + t := t.LatestExecutionPayloadBid + if t == nil { + t = new(ExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Dynamic Field #44 'PayloadExpectedWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2741625:], uint32(len(dst)-dstlen)) + t := t.PayloadExpectedWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconState from SSZ-encoded bytes. +func (t *BeaconState) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 3134845 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 3134845) + } + { // Field #0 'GenesisTime' (static) + buf := buf[0:8] + t.GenesisTime = binary.LittleEndian.Uint64(buf) + } + { // Field #1 'GenesisValidatorsRoot' (static) + buf := buf[8:40] + copy(t.GenesisValidatorsRoot[:], buf) + } + { // Field #2 'Slot' (static) + buf := buf[40:48] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'Fork' (static) + buf := buf[48:64] + if t.Fork == nil { + t.Fork = new(phase0.Fork) + } + if err = t.Fork.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Fork") + } + } + { // Field #4 'LatestBlockHeader' (static) + buf := buf[64:176] + if t.LatestBlockHeader == nil { + t.LatestBlockHeader = new(phase0.BeaconBlockHeader) + } + if err = t.LatestBlockHeader.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Field #5 'BlockRoots' (static) + buf := buf[176:262320] + val1 := t.BlockRoots + val1 = sszutils.ExpandSlice(val1, 8192) + sszutils.UnmarshalFixedBytesSlice(val1[:8192], buf) + t.BlockRoots = val1 + } + { // Field #6 'StateRoots' (static) + buf := buf[262320:524464] + val2 := t.StateRoots + val2 = sszutils.ExpandSlice(val2, 8192) + sszutils.UnmarshalFixedBytesSlice(val2[:8192], buf) + t.StateRoots = val2 + } + // Field #7 'HistoricalRoots' (offset) + offset7 := int(binary.LittleEndian.Uint32(buf[524464:524468])) + if offset7 != 3134845 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset7, 3134845), "HistoricalRoots:o") + } + { // Field #8 'ETH1Data' (static) + buf := buf[524468:524540] + if t.ETH1Data == nil { + t.ETH1Data = new(phase0.ETH1Data) + } + if err = t.ETH1Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + // Field #9 'ETH1DataVotes' (offset) + offset9 := int(binary.LittleEndian.Uint32(buf[524540:524544])) + if offset9 < offset7 || offset9 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset9, offset7, buflen), "ETH1DataVotes:o") + } + { // Field #10 'ETH1DepositIndex' (static) + buf := buf[524544:524552] + t.ETH1DepositIndex = binary.LittleEndian.Uint64(buf) + } + // Field #11 'Validators' (offset) + offset11 := int(binary.LittleEndian.Uint32(buf[524552:524556])) + if offset11 < offset9 || offset11 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset11, offset9, buflen), "Validators:o") + } + // Field #12 'Balances' (offset) + offset12 := int(binary.LittleEndian.Uint32(buf[524556:524560])) + if offset12 < offset11 || offset12 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset12, offset11, buflen), "Balances:o") + } + { // Field #13 'RANDAOMixes' (static) + buf := buf[524560:2621712] + val3 := t.RANDAOMixes + val3 = sszutils.ExpandSlice(val3, 65536) + sszutils.UnmarshalFixedBytesSlice(val3[:65536], buf) + t.RANDAOMixes = val3 + } + { // Field #14 'Slashings' (static) + buf := buf[2621712:2687248] + val4 := t.Slashings + val4 = sszutils.ExpandSlice(val4, 8192) + sszutils.UnmarshalUint64Slice(val4[:8192], buf) + t.Slashings = val4 + } + // Field #15 'PreviousEpochParticipation' (offset) + offset15 := int(binary.LittleEndian.Uint32(buf[2687248:2687252])) + if offset15 < offset12 || offset15 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset15, offset12, buflen), "PreviousEpochParticipation:o") + } + // Field #16 'CurrentEpochParticipation' (offset) + offset16 := int(binary.LittleEndian.Uint32(buf[2687252:2687256])) + if offset16 < offset15 || offset16 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset16, offset15, buflen), "CurrentEpochParticipation:o") + } + { // Field #17 'JustificationBits' (static) + buf := buf[2687256:2687257] + t.JustificationBits = sszutils.ExpandSlice(t.JustificationBits, 1) + copy(t.JustificationBits[:], buf) + } + { // Field #18 'PreviousJustifiedCheckpoint' (static) + buf := buf[2687257:2687297] + if t.PreviousJustifiedCheckpoint == nil { + t.PreviousJustifiedCheckpoint = new(phase0.Checkpoint) + } + if err = t.PreviousJustifiedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Field #19 'CurrentJustifiedCheckpoint' (static) + buf := buf[2687297:2687337] + if t.CurrentJustifiedCheckpoint == nil { + t.CurrentJustifiedCheckpoint = new(phase0.Checkpoint) + } + if err = t.CurrentJustifiedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Field #20 'FinalizedCheckpoint' (static) + buf := buf[2687337:2687377] + if t.FinalizedCheckpoint == nil { + t.FinalizedCheckpoint = new(phase0.Checkpoint) + } + if err = t.FinalizedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + // Field #21 'InactivityScores' (offset) + offset21 := int(binary.LittleEndian.Uint32(buf[2687377:2687381])) + if offset21 < offset16 || offset21 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset21, offset16, buflen), "InactivityScores:o") + } + { // Field #22 'CurrentSyncCommittee' (static) + buf := buf[2687381:2712005] + if t.CurrentSyncCommittee == nil { + t.CurrentSyncCommittee = new(altair.SyncCommittee) + } + if err = t.CurrentSyncCommittee.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Field #23 'NextSyncCommittee' (static) + buf := buf[2712005:2736629] + if t.NextSyncCommittee == nil { + t.NextSyncCommittee = new(altair.SyncCommittee) + } + if err = t.NextSyncCommittee.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Field #24 'LatestBlockHash' (static) + buf := buf[2736629:2736661] + copy(t.LatestBlockHash[:], buf) + } + { // Field #25 'NextWithdrawalIndex' (static) + buf := buf[2736661:2736669] + t.NextWithdrawalIndex = capella.WithdrawalIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #26 'NextWithdrawalValidatorIndex' (static) + buf := buf[2736669:2736677] + t.NextWithdrawalValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + // Field #27 'HistoricalSummaries' (offset) + offset27 := int(binary.LittleEndian.Uint32(buf[2736677:2736681])) + if offset27 < offset21 || offset27 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset27, offset21, buflen), "HistoricalSummaries:o") + } + { // Field #28 'DepositRequestsStartIndex' (static) + buf := buf[2736681:2736689] + t.DepositRequestsStartIndex = binary.LittleEndian.Uint64(buf) + } + { // Field #29 'DepositBalanceToConsume' (static) + buf := buf[2736689:2736697] + t.DepositBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #30 'ExitBalanceToConsume' (static) + buf := buf[2736697:2736705] + t.ExitBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #31 'EarliestExitEpoch' (static) + buf := buf[2736705:2736713] + t.EarliestExitEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + { // Field #32 'ConsolidationBalanceToConsume' (static) + buf := buf[2736713:2736721] + t.ConsolidationBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #33 'EarliestConsolidationEpoch' (static) + buf := buf[2736721:2736729] + t.EarliestConsolidationEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + // Field #34 'PendingDeposits' (offset) + offset34 := int(binary.LittleEndian.Uint32(buf[2736729:2736733])) + if offset34 < offset27 || offset34 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset34, offset27, buflen), "PendingDeposits:o") + } + // Field #35 'PendingPartialWithdrawals' (offset) + offset35 := int(binary.LittleEndian.Uint32(buf[2736733:2736737])) + if offset35 < offset34 || offset35 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset35, offset34, buflen), "PendingPartialWithdrawals:o") + } + // Field #36 'PendingConsolidations' (offset) + offset36 := int(binary.LittleEndian.Uint32(buf[2736737:2736741])) + if offset36 < offset35 || offset36 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset36, offset35, buflen), "PendingConsolidations:o") + } + { // Field #37 'ProposerLookahead' (static) + buf := buf[2736741:2737253] + val5 := t.ProposerLookahead + val5 = sszutils.ExpandSlice(val5, 64) + sszutils.UnmarshalUint64Slice(val5[:64], buf) + t.ProposerLookahead = val5 + } + // Field #38 'Builders' (offset) + offset38 := int(binary.LittleEndian.Uint32(buf[2737253:2737257])) + if offset38 < offset36 || offset38 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset38, offset36, buflen), "Builders:o") + } + { // Field #39 'NextWithdrawalBuilderIndex' (static) + buf := buf[2737257:2737265] + t.NextWithdrawalBuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #40 'ExecutionPayloadAvailability' (static) + buf := buf[2737265:2738289] + t.ExecutionPayloadAvailability = sszutils.ExpandSlice(t.ExecutionPayloadAvailability, 1024) + copy(t.ExecutionPayloadAvailability[:], buf) + } + { // Field #41 'BuilderPendingPayments' (static) + buf := buf[2738289:2741617] + val6 := t.BuilderPendingPayments + val6 = sszutils.ExpandSlice(val6, 64) + for idx1 := range 64 { + if val6[idx1] == nil { + val6[idx1] = new(BuilderPendingPayment) + } + buf := buf[52*idx1 : 52*(idx1+1)] + if err = val6[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + } + t.BuilderPendingPayments = val6 + } + // Field #42 'BuilderPendingWithdrawals' (offset) + offset42 := int(binary.LittleEndian.Uint32(buf[2741617:2741621])) + if offset42 < offset38 || offset42 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset42, offset38, buflen), "BuilderPendingWithdrawals:o") + } + // Field #43 'LatestExecutionPayloadBid' (offset) + offset43 := int(binary.LittleEndian.Uint32(buf[2741621:2741625])) + if offset43 < offset42 || offset43 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset43, offset42, buflen), "LatestExecutionPayloadBid:o") + } + // Field #44 'PayloadExpectedWithdrawals' (offset) + offset44 := int(binary.LittleEndian.Uint32(buf[2741625:2741629])) + if offset44 < offset43 || offset44 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset44, offset43, buflen), "PayloadExpectedWithdrawals:o") + } + { // Field #45 'PTCWindow' (static) + buf := buf[2741629:3134845] + val7 := t.PTCWindow + val7 = sszutils.ExpandSlice(val7, 96) + for idx1 := range 96 { + val8 := val7[idx1] + buf := buf[4096*idx1 : 4096*(idx1+1)] + val8 = sszutils.ExpandSlice(val8, 512) + sszutils.UnmarshalUint64Slice(val8[:512], buf) + val7[idx1] = val8 + } + t.PTCWindow = val7 + } + { // Field #7 'HistoricalRoots' (dynamic) + buf := buf[offset7:offset9] + val9 := t.HistoricalRoots + itemCount := len(buf) / 32 + if len(buf)%32 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 32), "HistoricalRoots") + } + if itemCount > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 16777216), "HistoricalRoots") + } + val9 = sszutils.ExpandSlice(val9, itemCount) + sszutils.UnmarshalFixedBytesSlice(val9[:itemCount], buf) + t.HistoricalRoots = val9 + } + { // Field #9 'ETH1DataVotes' (dynamic) + buf := buf[offset9:offset11] + val10 := t.ETH1DataVotes + itemCount := len(buf) / 72 + if len(buf)%72 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 72), "ETH1DataVotes") + } + if itemCount > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 2048), "ETH1DataVotes") + } + val10 = sszutils.ExpandSlice(val10, itemCount) + for idx1 := range itemCount { + if val10[idx1] == nil { + val10[idx1] = new(phase0.ETH1Data) + } + buf := buf[72*idx1 : 72*(idx1+1)] + if err = val10[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + } + t.ETH1DataVotes = val10 + } + { // Field #11 'Validators' (dynamic) + buf := buf[offset11:offset12] + val11 := t.Validators + itemCount := len(buf) / 121 + if len(buf)%121 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 121), "Validators") + } + val11 = sszutils.ExpandSlice(val11, itemCount) + for idx1 := range itemCount { + if val11[idx1] == nil { + val11[idx1] = new(phase0.Validator) + } + buf := buf[121*idx1 : 121*(idx1+1)] + if err = val11[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + } + t.Validators = val11 + } + { // Field #12 'Balances' (dynamic) + buf := buf[offset12:offset15] + val12 := t.Balances + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "Balances") + } + val12 = sszutils.ExpandSlice(val12, itemCount) + sszutils.UnmarshalUint64Slice(val12, buf) + t.Balances = val12 + } + { // Field #15 'PreviousEpochParticipation' (dynamic) + buf := buf[offset15:offset16] + val13 := t.PreviousEpochParticipation + itemCount := len(buf) + val13 = sszutils.ExpandSlice(val13, itemCount) + for idx1 := range itemCount { + buf := buf[1*idx1 : 1*(idx1+1)] + val13[idx1] = altair.ParticipationFlags(buf[0]) + } + t.PreviousEpochParticipation = val13 + } + { // Field #16 'CurrentEpochParticipation' (dynamic) + buf := buf[offset16:offset21] + val14 := t.CurrentEpochParticipation + itemCount := len(buf) + val14 = sszutils.ExpandSlice(val14, itemCount) + for idx1 := range itemCount { + buf := buf[1*idx1 : 1*(idx1+1)] + val14[idx1] = altair.ParticipationFlags(buf[0]) + } + t.CurrentEpochParticipation = val14 + } + { // Field #21 'InactivityScores' (dynamic) + buf := buf[offset21:offset27] + val15 := t.InactivityScores + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "InactivityScores") + } + val15 = sszutils.ExpandSlice(val15, itemCount) + sszutils.UnmarshalUint64Slice(val15, buf) + t.InactivityScores = val15 + } + { // Field #27 'HistoricalSummaries' (dynamic) + buf := buf[offset27:offset34] + val16 := t.HistoricalSummaries + itemCount := len(buf) / 64 + if len(buf)%64 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 64), "HistoricalSummaries") + } + if itemCount > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 16777216), "HistoricalSummaries") + } + val16 = sszutils.ExpandSlice(val16, itemCount) + for idx1 := range itemCount { + if val16[idx1] == nil { + val16[idx1] = new(capella.HistoricalSummary) + } + buf := buf[64*idx1 : 64*(idx1+1)] + if err = val16[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + } + t.HistoricalSummaries = val16 + } + { // Field #34 'PendingDeposits' (dynamic) + buf := buf[offset34:offset35] + val17 := t.PendingDeposits + itemCount := len(buf) / 192 + if len(buf)%192 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 192), "PendingDeposits") + } + val17 = sszutils.ExpandSlice(val17, itemCount) + for idx1 := range itemCount { + if val17[idx1] == nil { + val17[idx1] = new(electra.PendingDeposit) + } + buf := buf[192*idx1 : 192*(idx1+1)] + if err = val17[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + } + t.PendingDeposits = val17 + } + { // Field #35 'PendingPartialWithdrawals' (dynamic) + buf := buf[offset35:offset36] + val18 := t.PendingPartialWithdrawals + itemCount := len(buf) / 24 + if len(buf)%24 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 24), "PendingPartialWithdrawals") + } + val18 = sszutils.ExpandSlice(val18, itemCount) + for idx1 := range itemCount { + if val18[idx1] == nil { + val18[idx1] = new(electra.PendingPartialWithdrawal) + } + buf := buf[24*idx1 : 24*(idx1+1)] + if err = val18[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + } + t.PendingPartialWithdrawals = val18 + } + { // Field #36 'PendingConsolidations' (dynamic) + buf := buf[offset36:offset38] + val19 := t.PendingConsolidations + itemCount := len(buf) / 16 + if len(buf)%16 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 16), "PendingConsolidations") + } + val19 = sszutils.ExpandSlice(val19, itemCount) + for idx1 := range itemCount { + if val19[idx1] == nil { + val19[idx1] = new(electra.PendingConsolidation) + } + buf := buf[16*idx1 : 16*(idx1+1)] + if err = val19[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + } + t.PendingConsolidations = val19 + } + { // Field #38 'Builders' (dynamic) + buf := buf[offset38:offset42] + val20 := t.Builders + itemCount := len(buf) / 93 + if len(buf)%93 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 93), "Builders") + } + val20 = sszutils.ExpandSlice(val20, itemCount) + for idx1 := range itemCount { + if val20[idx1] == nil { + val20[idx1] = new(Builder) + } + buf := buf[93*idx1 : 93*(idx1+1)] + if err = val20[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + } + t.Builders = val20 + } + { // Field #42 'BuilderPendingWithdrawals' (dynamic) + buf := buf[offset42:offset43] + val21 := t.BuilderPendingWithdrawals + itemCount := len(buf) / 36 + if len(buf)%36 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 36), "BuilderPendingWithdrawals") + } + val21 = sszutils.ExpandSlice(val21, itemCount) + for idx1 := range itemCount { + if val21[idx1] == nil { + val21[idx1] = new(BuilderPendingWithdrawal) + } + buf := buf[36*idx1 : 36*(idx1+1)] + if err = val21[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + } + t.BuilderPendingWithdrawals = val21 + } + { // Field #43 'LatestExecutionPayloadBid' (dynamic) + buf := buf[offset43:offset44] + if t.LatestExecutionPayloadBid == nil { + t.LatestExecutionPayloadBid = new(ExecutionPayloadBid) + } + if err = t.LatestExecutionPayloadBid.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Field #44 'PayloadExpectedWithdrawals' (dynamic) + buf := buf[offset44:] + val22 := t.PayloadExpectedWithdrawals + itemCount := len(buf) / 44 + if len(buf)%44 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 44), "PayloadExpectedWithdrawals") + } + val22 = sszutils.ExpandSlice(val22, itemCount) + for idx1 := range itemCount { + if val22[idx1] == nil { + val22[idx1] = new(capella.Withdrawal) + } + buf := buf[44*idx1 : 44*(idx1+1)] + if err = val22[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + } + t.PayloadExpectedWithdrawals = val22 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconState. +func (t *BeaconState) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconState) + } + // Field #0 'GenesisTime' static (8 bytes) + // Field #1 'GenesisValidatorsRoot' static (32 bytes) + // Field #2 'Slot' static (8 bytes) + // Field #3 'Fork' static (16 bytes) + // Field #4 'LatestBlockHeader' static (112 bytes) + // Field #5 'BlockRoots' static (262144 bytes) + // Field #6 'StateRoots' static (262144 bytes) + // Field #7 'HistoricalRoots' offset (4 bytes) + // Field #8 'ETH1Data' static (72 bytes) + // Field #9 'ETH1DataVotes' offset (4 bytes) + // Field #10 'ETH1DepositIndex' static (8 bytes) + // Field #11 'Validators' offset (4 bytes) + // Field #12 'Balances' offset (4 bytes) + // Field #13 'RANDAOMixes' static (2097152 bytes) + // Field #14 'Slashings' static (65536 bytes) + // Field #15 'PreviousEpochParticipation' offset (4 bytes) + // Field #16 'CurrentEpochParticipation' offset (4 bytes) + // Field #17 'JustificationBits' static (1 bytes) + // Field #18 'PreviousJustifiedCheckpoint' static (40 bytes) + // Field #19 'CurrentJustifiedCheckpoint' static (40 bytes) + // Field #20 'FinalizedCheckpoint' static (40 bytes) + // Field #21 'InactivityScores' offset (4 bytes) + // Field #22 'CurrentSyncCommittee' static (24624 bytes) + // Field #23 'NextSyncCommittee' static (24624 bytes) + // Field #24 'LatestBlockHash' static (32 bytes) + // Field #25 'NextWithdrawalIndex' static (8 bytes) + // Field #26 'NextWithdrawalValidatorIndex' static (8 bytes) + // Field #27 'HistoricalSummaries' offset (4 bytes) + // Field #28 'DepositRequestsStartIndex' static (8 bytes) + // Field #29 'DepositBalanceToConsume' static (8 bytes) + // Field #30 'ExitBalanceToConsume' static (8 bytes) + // Field #31 'EarliestExitEpoch' static (8 bytes) + // Field #32 'ConsolidationBalanceToConsume' static (8 bytes) + // Field #33 'EarliestConsolidationEpoch' static (8 bytes) + // Field #34 'PendingDeposits' offset (4 bytes) + // Field #35 'PendingPartialWithdrawals' offset (4 bytes) + // Field #36 'PendingConsolidations' offset (4 bytes) + // Field #37 'ProposerLookahead' static (512 bytes) + // Field #38 'Builders' offset (4 bytes) + // Field #39 'NextWithdrawalBuilderIndex' static (8 bytes) + // Field #40 'ExecutionPayloadAvailability' static (1024 bytes) + // Field #41 'BuilderPendingPayments' static (3328 bytes) + // Field #42 'BuilderPendingWithdrawals' offset (4 bytes) + // Field #43 'LatestExecutionPayloadBid' offset (4 bytes) + // Field #44 'PayloadExpectedWithdrawals' offset (4 bytes) + // Field #45 'PTCWindow' static (393216 bytes) + size += 3134845 + { // Dynamic field #7 'HistoricalRoots' + size += len(t.HistoricalRoots) * 32 + } + { // Dynamic field #9 'ETH1DataVotes' + size += len(t.ETH1DataVotes) * 72 + } + { // Dynamic field #11 'Validators' + size += len(t.Validators) * 121 + } + { // Dynamic field #12 'Balances' + size += len(t.Balances) * 8 + } + { // Dynamic field #15 'PreviousEpochParticipation' + size += len(t.PreviousEpochParticipation) + } + { // Dynamic field #16 'CurrentEpochParticipation' + size += len(t.CurrentEpochParticipation) + } + { // Dynamic field #21 'InactivityScores' + size += len(t.InactivityScores) * 8 + } + { // Dynamic field #27 'HistoricalSummaries' + size += len(t.HistoricalSummaries) * 64 + } + { // Dynamic field #34 'PendingDeposits' + size += len(t.PendingDeposits) * 192 + } + { // Dynamic field #35 'PendingPartialWithdrawals' + size += len(t.PendingPartialWithdrawals) * 24 + } + { // Dynamic field #36 'PendingConsolidations' + size += len(t.PendingConsolidations) * 16 + } + { // Dynamic field #38 'Builders' + size += len(t.Builders) * 93 + } + { // Dynamic field #42 'BuilderPendingWithdrawals' + size += len(t.BuilderPendingWithdrawals) * 36 + } + { // Dynamic field #43 'LatestExecutionPayloadBid' + size += t.LatestExecutionPayloadBid.SizeSSZ() + } + { // Dynamic field #44 'PayloadExpectedWithdrawals' + size += len(t.PayloadExpectedWithdrawals) * 44 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconState. +func (t *BeaconState) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconState using the given hash walker. +func (t *BeaconState) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconState) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'GenesisTime' + hh.PutUint64(t.GenesisTime) + } + { // Field #1 'GenesisValidatorsRoot' + hh.PutBytes(t.GenesisValidatorsRoot[:32]) + } + { // Field #2 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #3 'Fork' + t := t.Fork + if t == nil { + t = new(phase0.Fork) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Fork") + } + } + { // Field #4 'LatestBlockHeader' + t := t.LatestBlockHeader + if t == nil { + t = new(phase0.BeaconBlockHeader) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Field #5 'BlockRoots' + t := t.BlockRoots + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "BlockRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val1 *phase0.Root + for idx1 := range 8192 { + if idx1 < vlen { + val1 = &t[idx1] + } else if idx1 == vlen { + val1 = new(phase0.Root) + } + hh.PutBytes(val1[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #6 'StateRoots' + t := t.StateRoots + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "StateRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val2 *phase0.Root + for idx1 := range 8192 { + if idx1 < vlen { + val2 = &t[idx1] + } else if idx1 == vlen { + val2 = new(phase0.Root) + } + hh.PutBytes(val2[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #7 'HistoricalRoots' + t := t.HistoricalRoots + vlen := uint64(len(t)) + if vlen > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + } + { // Field #8 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #9 'ETH1DataVotes' + t := t.ETH1DataVotes + vlen := uint64(len(t)) + if vlen > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "ETH1DataVotes") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(2048, vlen, 32)) + } + { // Field #10 'ETH1DepositIndex' + hh.PutUint64(t.ETH1DepositIndex) + } + { // Field #11 'Validators' + t := t.Validators + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.Validator) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #12 'Balances' + t := t.Balances + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #13 'RANDAOMixes' + t := t.RANDAOMixes + vlen := len(t) + if vlen > 65536 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 65536), "RANDAOMixes") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val3 *phase0.Root + for idx1 := range 65536 { + if idx1 < vlen { + val3 = &t[idx1] + } else if idx1 == vlen { + val3 = new(phase0.Root) + } + hh.PutBytes(val3[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #14 'Slashings' + t := t.Slashings + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "Slashings") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val4, val4Empty phase0.Gwei + for idx1 := range 8192 { + if idx1 < vlen { + val4 = t[idx1] + } else if idx1 == vlen { + val4 = val4Empty + } + hh.AppendUint64(uint64(val4)) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + } + { // Field #15 'PreviousEpochParticipation' + t := t.PreviousEpochParticipation + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.AppendUint8(uint8(t[idx1])) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #16 'CurrentEpochParticipation' + t := t.CurrentEpochParticipation + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.AppendUint8(uint8(t[idx1])) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #17 'JustificationBits' + vlen := len(t.JustificationBits) + if vlen > 1 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1), "JustificationBits") + } + val := t.JustificationBits[:] + if vlen < 1 { + val = sszutils.AppendZeroPadding(val, (1-vlen)*1) + } + hh.PutBytes(val[:1]) + } + { // Field #18 'PreviousJustifiedCheckpoint' + t := t.PreviousJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Field #19 'CurrentJustifiedCheckpoint' + t := t.CurrentJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Field #20 'FinalizedCheckpoint' + t := t.FinalizedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + { // Field #21 'InactivityScores' + t := t.InactivityScores + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #22 'CurrentSyncCommittee' + t := t.CurrentSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Field #23 'NextSyncCommittee' + t := t.NextSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Field #24 'LatestBlockHash' + hh.PutBytes(t.LatestBlockHash[:32]) + } + { // Field #25 'NextWithdrawalIndex' + hh.PutUint64(uint64(t.NextWithdrawalIndex)) + } + { // Field #26 'NextWithdrawalValidatorIndex' + hh.PutUint64(uint64(t.NextWithdrawalValidatorIndex)) + } + { // Field #27 'HistoricalSummaries' + t := t.HistoricalSummaries + vlen := uint64(len(t)) + if vlen > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.HistoricalSummary) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + } + { // Field #28 'DepositRequestsStartIndex' + hh.PutUint64(t.DepositRequestsStartIndex) + } + { // Field #29 'DepositBalanceToConsume' + hh.PutUint64(uint64(t.DepositBalanceToConsume)) + } + { // Field #30 'ExitBalanceToConsume' + hh.PutUint64(uint64(t.ExitBalanceToConsume)) + } + { // Field #31 'EarliestExitEpoch' + hh.PutUint64(uint64(t.EarliestExitEpoch)) + } + { // Field #32 'ConsolidationBalanceToConsume' + hh.PutUint64(uint64(t.ConsolidationBalanceToConsume)) + } + { // Field #33 'EarliestConsolidationEpoch' + hh.PutUint64(uint64(t.EarliestConsolidationEpoch)) + } + { // Field #34 'PendingDeposits' + t := t.PendingDeposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingDeposit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #35 'PendingPartialWithdrawals' + t := t.PendingPartialWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingPartialWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #36 'PendingConsolidations' + t := t.PendingConsolidations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingConsolidation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #37 'ProposerLookahead' + t := t.ProposerLookahead + vlen := len(t) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "ProposerLookahead") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val5, val5Empty phase0.ValidatorIndex + for idx1 := range 64 { + if idx1 < vlen { + val5 = t[idx1] + } else if idx1 == vlen { + val5 = val5Empty + } + hh.AppendUint64(uint64(val5)) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + } + { // Field #38 'Builders' + t := t.Builders + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(Builder) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #39 'NextWithdrawalBuilderIndex' + hh.PutUint64(uint64(t.NextWithdrawalBuilderIndex)) + } + { // Field #40 'ExecutionPayloadAvailability' + vlen := len(t.ExecutionPayloadAvailability) + if vlen > 1024 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1024), "ExecutionPayloadAvailability") + } + val := t.ExecutionPayloadAvailability[:] + if vlen < 1024 { + val = sszutils.AppendZeroPadding(val, (1024-vlen)*1) + } + hh.PutBytes(val[:1024]) + } + { // Field #41 'BuilderPendingPayments' + t := t.BuilderPendingPayments + vlen := len(t) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "BuilderPendingPayments") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val6 *BuilderPendingPayment + for idx1 := range 64 { + if idx1 < vlen { + val6 = t[idx1] + } else if idx1 == vlen { + val6 = new(BuilderPendingPayment) + } + if val6 == nil { + val6 = new(BuilderPendingPayment) + } + if err := val6.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #42 'BuilderPendingWithdrawals' + t := t.BuilderPendingWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #43 'LatestExecutionPayloadBid' + t := t.LatestExecutionPayloadBid + if t == nil { + t = new(ExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Field #44 'PayloadExpectedWithdrawals' + t := t.PayloadExpectedWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #45 'PTCWindow' + t := t.PTCWindow + vlen := len(t) + if vlen > 96 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 96), "PTCWindow") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val7, val7Empty []phase0.ValidatorIndex + for idx1 := range 96 { + if idx1 < vlen { + val7 = t[idx1] + } else if idx1 == vlen { + val7 = val7Empty + } + vlen := len(val7) + if vlen > 512 { + return sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, 512), "PTCWindow[%d]", idx1) + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val8, val8Empty phase0.ValidatorIndex + for idx2 := range 512 { + if idx2 < vlen { + val8 = val7[idx2] + } else if idx2 == vlen { + val8 = val8Empty + } + hh.AppendUint64(uint64(val8)) + if (idx2+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0x3f}) + return nil +} diff --git a/spec/gloas/beaconstate_yaml.go b/spec/gloas/beaconstate_yaml.go new file mode 100644 index 000000000..4e7d5e01f --- /dev/null +++ b/spec/gloas/beaconstate_yaml.go @@ -0,0 +1,150 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// beaconStateYAML is the spec representation of the struct. +type beaconStateYAML struct { + GenesisTime uint64 `yaml:"genesis_time"` + GenesisValidatorsRoot phase0.Root `yaml:"genesis_validators_root"` + Slot phase0.Slot `yaml:"slot"` + Fork *phase0.Fork `yaml:"fork"` + LatestBlockHeader *phase0.BeaconBlockHeader `yaml:"latest_block_header"` + BlockRoots []phase0.Root `yaml:"block_roots"` + StateRoots []phase0.Root `yaml:"state_roots"` + HistoricalRoots []phase0.Root `yaml:"historical_roots"` + ETH1Data *phase0.ETH1Data `yaml:"eth1_data"` + ETH1DataVotes []*phase0.ETH1Data `yaml:"eth1_data_votes"` + ETH1DepositIndex uint64 `yaml:"eth1_deposit_index"` + Validators []*phase0.Validator `yaml:"validators"` + Balances []phase0.Gwei `yaml:"balances"` + RANDAOMixes []phase0.Root `yaml:"randao_mixes"` + Slashings []phase0.Gwei `yaml:"slashings"` + PreviousEpochParticipation []altair.ParticipationFlags `yaml:"previous_epoch_participation"` + CurrentEpochParticipation []altair.ParticipationFlags `yaml:"current_epoch_participation"` + JustificationBits string `yaml:"justification_bits"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `yaml:"previous_justified_checkpoint"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `yaml:"current_justified_checkpoint"` + FinalizedCheckpoint *phase0.Checkpoint `yaml:"finalized_checkpoint"` + InactivityScores []uint64 `yaml:"inactivity_scores"` + CurrentSyncCommittee *altair.SyncCommittee `yaml:"current_sync_committee"` + NextSyncCommittee *altair.SyncCommittee `yaml:"next_sync_committee"` + LatestBlockHash phase0.Hash32 `yaml:"latest_block_hash"` + NextWithdrawalIndex capella.WithdrawalIndex `yaml:"next_withdrawal_index"` + NextWithdrawalValidatorIndex phase0.ValidatorIndex `yaml:"next_withdrawal_validator_index"` + HistoricalSummaries []*capella.HistoricalSummary `yaml:"historical_summaries"` + DepositRequestsStartIndex uint64 `yaml:"deposit_requests_start_index"` + DepositBalanceToConsume phase0.Gwei `yaml:"deposit_balance_to_consume"` + ExitBalanceToConsume phase0.Gwei `yaml:"exit_balance_to_consume"` + EarliestExitEpoch phase0.Epoch `yaml:"earliest_exit_epoch"` + ConsolidationBalanceToConsume phase0.Gwei `yaml:"consolidation_balance_to_consume"` + EarliestConsolidationEpoch phase0.Epoch `yaml:"earliest_consolidation_epoch"` + PendingDeposits []*electra.PendingDeposit `yaml:"pending_deposits"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `yaml:"pending_partial_withdrawals"` + PendingConsolidations []*electra.PendingConsolidation `yaml:"pending_consolidations"` + ProposerLookahead []phase0.ValidatorIndex `yaml:"proposer_lookahead"` + Builders []*Builder `yaml:"builders"` + NextWithdrawalBuilderIndex BuilderIndex `yaml:"next_withdrawal_builder_index"` + ExecutionPayloadAvailability string `yaml:"execution_payload_availability"` + BuilderPendingPayments []*BuilderPendingPayment `yaml:"builder_pending_payments"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `yaml:"builder_pending_withdrawals"` + LatestExecutionPayloadBid *ExecutionPayloadBid `yaml:"latest_execution_payload_bid"` + PayloadExpectedWithdrawals []*capella.Withdrawal `yaml:"payload_expected_withdrawals"` + PTCWindow [][]phase0.ValidatorIndex `yaml:"ptc_window"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconState) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconStateYAML{ + GenesisTime: b.GenesisTime, + GenesisValidatorsRoot: b.GenesisValidatorsRoot, + Slot: b.Slot, + Fork: b.Fork, + LatestBlockHeader: b.LatestBlockHeader, + BlockRoots: b.BlockRoots, + StateRoots: b.StateRoots, + HistoricalRoots: b.HistoricalRoots, + ETH1Data: b.ETH1Data, + ETH1DataVotes: b.ETH1DataVotes, + ETH1DepositIndex: b.ETH1DepositIndex, + Validators: b.Validators, + Balances: b.Balances, + RANDAOMixes: b.RANDAOMixes, + Slashings: b.Slashings, + PreviousEpochParticipation: b.PreviousEpochParticipation, + CurrentEpochParticipation: b.CurrentEpochParticipation, + JustificationBits: fmt.Sprintf("%#x", b.JustificationBits.Bytes()), + PreviousJustifiedCheckpoint: b.PreviousJustifiedCheckpoint, + CurrentJustifiedCheckpoint: b.CurrentJustifiedCheckpoint, + FinalizedCheckpoint: b.FinalizedCheckpoint, + InactivityScores: b.InactivityScores, + CurrentSyncCommittee: b.CurrentSyncCommittee, + NextSyncCommittee: b.NextSyncCommittee, + LatestBlockHash: b.LatestBlockHash, + NextWithdrawalIndex: b.NextWithdrawalIndex, + NextWithdrawalValidatorIndex: b.NextWithdrawalValidatorIndex, + HistoricalSummaries: b.HistoricalSummaries, + DepositRequestsStartIndex: b.DepositRequestsStartIndex, + DepositBalanceToConsume: b.DepositBalanceToConsume, + ExitBalanceToConsume: b.ExitBalanceToConsume, + EarliestExitEpoch: b.EarliestExitEpoch, + ConsolidationBalanceToConsume: b.ConsolidationBalanceToConsume, + EarliestConsolidationEpoch: b.EarliestConsolidationEpoch, + PendingDeposits: b.PendingDeposits, + PendingPartialWithdrawals: b.PendingPartialWithdrawals, + PendingConsolidations: b.PendingConsolidations, + ProposerLookahead: b.ProposerLookahead, + Builders: b.Builders, + NextWithdrawalBuilderIndex: b.NextWithdrawalBuilderIndex, + ExecutionPayloadAvailability: fmt.Sprintf("%#x", b.ExecutionPayloadAvailability), + BuilderPendingPayments: b.BuilderPendingPayments, + BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, + LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, + PayloadExpectedWithdrawals: b.PayloadExpectedWithdrawals, + PTCWindow: b.PTCWindow, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconState) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled beaconStateJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go new file mode 100644 index 000000000..422b7d65f --- /dev/null +++ b/spec/gloas/builder.go @@ -0,0 +1,188 @@ +// Copyright © 2020 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// Builder is the Ethereum 2 builder structure. +type Builder struct { + PublicKey phase0.BLSPubKey `ssz-size:"48"` + Version uint8 + ExecutionAddress bellatrix.ExecutionAddress `ssz-size:"20"` + Balance phase0.Gwei + DepositEpoch phase0.Epoch + WithdrawableEpoch phase0.Epoch +} + +// builderJSON is the spec representation of the struct. +type builderJSON struct { + PublicKey string `json:"pubkey"` + Version uint8 `json:"version"` + ExecutionAddress string `json:"execution_address"` + Balance string `json:"balance"` + DepositEpoch string `json:"deposit_epoch"` + WithdrawableEpoch string `json:"withdrawable_epoch"` +} + +// builderYAML is the spec representation of the struct. +type builderYAML struct { + PublicKey string `yaml:"pubkey"` + Version uint8 `yaml:"version"` + ExecutionAddress string `yaml:"execution_address"` + Balance string `yaml:"balance"` + DepositEpoch string `yaml:"deposit_epoch"` + WithdrawableEpoch string `yaml:"withdrawable_epoch"` +} + +// MarshalJSON implements json.Marshaler. +func (v *Builder) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderJSON{ + PublicKey: fmt.Sprintf("%#x", v.PublicKey), + Version: v.Version, + ExecutionAddress: v.ExecutionAddress.String(), + Balance: fmt.Sprintf("%d", v.Balance), + DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), + WithdrawableEpoch: fmt.Sprintf("%d", v.WithdrawableEpoch), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *Builder) UnmarshalJSON(input []byte) error { + var builderJSON builderJSON + if err := json.Unmarshal(input, &builderJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return v.unpack(&builderJSON) +} + +func (v *Builder) unpack(builderJSON *builderJSON) error { + if builderJSON.PublicKey == "" { + return errors.New("public key missing") + } + + publicKey, err := hex.DecodeString(strings.TrimPrefix(builderJSON.PublicKey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(publicKey) != phase0.PublicKeyLength { + return fmt.Errorf("incorrect length %d for public key", len(publicKey)) + } + + copy(v.PublicKey[:], publicKey) + + if builderJSON.ExecutionAddress == "" { + return errors.New("execution address missing") + } + + executionAddress, err := hex.DecodeString(strings.TrimPrefix(builderJSON.ExecutionAddress, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for execution address") + } + + if len(executionAddress) != bellatrix.ExecutionAddressLength { + return errors.New("incorrect length for fee recipient") + } + + copy(v.ExecutionAddress[:], executionAddress) + + if len(executionAddress) != bellatrix.ExecutionAddressLength { + return fmt.Errorf("incorrect length %d for execution address", len(executionAddress)) + } + + if builderJSON.Balance == "" { + return errors.New("balance missing") + } + + balance, err := strconv.ParseUint(builderJSON.Balance, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for effective balance") + } + + v.Balance = phase0.Gwei(balance) + + if builderJSON.DepositEpoch == "" { + return errors.New("deposit epoch missing") + } + + depositEpoch, err := strconv.ParseUint(builderJSON.DepositEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for deposit epoch") + } + + v.DepositEpoch = phase0.Epoch(depositEpoch) + + if builderJSON.WithdrawableEpoch == "" { + return errors.New("withdrawable epoch missing") + } + + withdrawableEpoch, err := strconv.ParseUint(builderJSON.WithdrawableEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for withdrawable epoch") + } + + v.WithdrawableEpoch = phase0.Epoch(withdrawableEpoch) + + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (v *Builder) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderYAML{ + PublicKey: fmt.Sprintf("%#x", v.PublicKey), + ExecutionAddress: v.ExecutionAddress.String(), + Balance: fmt.Sprintf("%d", v.Balance), + DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), + WithdrawableEpoch: fmt.Sprintf("%d", v.WithdrawableEpoch), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (v *Builder) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var builderJSON builderJSON + if err := yaml.Unmarshal(input, &builderJSON); err != nil { + return err + } + + return v.unpack(&builderJSON) +} + +// String returns a string version of the structure. +func (v *Builder) String() string { + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builder_ssz.go b/spec/gloas/builder_ssz.go new file mode 100644 index 000000000..5ca3d1dff --- /dev/null +++ b/spec/gloas/builder_ssz.go @@ -0,0 +1,130 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 2814233bb3b8337c061ff6e590643587ba7f097131ad6f291a424c8a8ee5d512 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[Builder](`ssz-static:"true"`) + +// MarshalSSZ marshals the *Builder to SSZ-encoded bytes. +func (t *Builder) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *Builder to SSZ-encoded bytes, appending to the provided buffer. +func (t *Builder) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(Builder) + } + { // Static Field #0 'PublicKey' + dst = append(dst, t.PublicKey[:48]...) + } + { // Static Field #1 'Version' + dst = append(dst, byte(t.Version)) + } + { // Static Field #2 'ExecutionAddress' + dst = append(dst, t.ExecutionAddress[:20]...) + } + { // Static Field #3 'Balance' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Balance)) + } + { // Static Field #4 'DepositEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.DepositEpoch)) + } + { // Static Field #5 'WithdrawableEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.WithdrawableEpoch)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *Builder from SSZ-encoded bytes. +func (t *Builder) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 93 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 93) + } + if buflen > 93 { + return sszutils.ErrTrailingDataFn(buflen - 93) + } + { // Field #0 'PublicKey' (static) + buf := buf[0:48] + copy(t.PublicKey[:], buf) + } + { // Field #1 'Version' (static) + buf := buf[48:49] + t.Version = buf[0] + } + { // Field #2 'ExecutionAddress' (static) + buf := buf[49:69] + copy(t.ExecutionAddress[:], buf) + } + { // Field #3 'Balance' (static) + buf := buf[69:77] + t.Balance = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #4 'DepositEpoch' (static) + buf := buf[77:85] + t.DepositEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + { // Field #5 'WithdrawableEpoch' (static) + buf := buf[85:93] + t.WithdrawableEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *Builder. +func (t *Builder) SizeSSZ() (size int) { + return 93 +} + +// HashTreeRoot computes the SSZ hash tree root of the *Builder. +func (t *Builder) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *Builder using the given hash walker. +func (t *Builder) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(Builder) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'PublicKey' + hh.PutBytes(t.PublicKey[:48]) + } + { // Field #1 'Version' + hh.PutUint8(t.Version) + } + { // Field #2 'ExecutionAddress' + hh.PutBytes(t.ExecutionAddress[:20]) + } + { // Field #3 'Balance' + hh.PutUint64(uint64(t.Balance)) + } + { // Field #4 'DepositEpoch' + hh.PutUint64(uint64(t.DepositEpoch)) + } + { // Field #5 'WithdrawableEpoch' + hh.PutUint64(uint64(t.WithdrawableEpoch)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderdepositrequest.go b/spec/gloas/builderdepositrequest.go new file mode 100644 index 000000000..ce32cd019 --- /dev/null +++ b/spec/gloas/builderdepositrequest.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderDepositRequest represents a builder deposit request (EIP-8282). +type BuilderDepositRequest struct { + Pubkey phase0.BLSPubKey `ssz-size:"48"` + WithdrawalCredentials []byte `ssz-size:"32"` + Amount phase0.Gwei + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (b *BuilderDepositRequest) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderdepositrequest_json.go b/spec/gloas/builderdepositrequest_json.go new file mode 100644 index 000000000..2c0f876f3 --- /dev/null +++ b/spec/gloas/builderdepositrequest_json.go @@ -0,0 +1,110 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderDepositRequestJSON is the spec representation of the struct. +type builderDepositRequestJSON struct { + Pubkey string `json:"pubkey"` + WithdrawalCredentials string `json:"withdrawal_credentials"` + Amount string `json:"amount"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderDepositRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderDepositRequestJSON{ + Pubkey: fmt.Sprintf("%#x", b.Pubkey), + WithdrawalCredentials: fmt.Sprintf("%#x", b.WithdrawalCredentials), + Amount: fmt.Sprintf("%d", b.Amount), + Signature: fmt.Sprintf("%#x", b.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderDepositRequest) UnmarshalJSON(input []byte) error { + var data builderDepositRequestJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return b.unpack(&data) +} + +func (b *BuilderDepositRequest) unpack(data *builderDepositRequestJSON) error { + if data.Pubkey == "" { + return errors.New("public key missing") + } + + pubkey, err := hex.DecodeString(strings.TrimPrefix(data.Pubkey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(pubkey) != phase0.PublicKeyLength { + return errors.New("incorrect length for public key") + } + + copy(b.Pubkey[:], pubkey) + + if data.WithdrawalCredentials == "" { + return errors.New("withdrawal credentials missing") + } + + if b.WithdrawalCredentials, err = hex.DecodeString(strings.TrimPrefix(data.WithdrawalCredentials, "0x")); err != nil { + return errors.Wrap(err, "invalid value for withdrawal credentials") + } + + if len(b.WithdrawalCredentials) != phase0.HashLength { + return errors.New("incorrect length for withdrawal credentials") + } + + if data.Amount == "" { + return errors.New("amount missing") + } + + amount, err := strconv.ParseUint(data.Amount, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for amount") + } + + b.Amount = phase0.Gwei(amount) + + if data.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(b.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/builderdepositrequest_ssz.go b/spec/gloas/builderdepositrequest_ssz.go new file mode 100644 index 000000000..61f709832 --- /dev/null +++ b/spec/gloas/builderdepositrequest_ssz.go @@ -0,0 +1,126 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 66ee5efe5c0a7b4add4b02bdb68c7eb0f4db97255ccbbefdf60fc603909db5dd +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderDepositRequest](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderDepositRequest to SSZ-encoded bytes. +func (t *BuilderDepositRequest) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderDepositRequest to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderDepositRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderDepositRequest) + } + { // Static Field #0 'Pubkey' + dst = append(dst, t.Pubkey[:48]...) + } + { // Static Field #1 'WithdrawalCredentials' + vlen := len(t.WithdrawalCredentials) + if vlen > 32 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 32), "WithdrawalCredentials") + } + dst = append(dst, t.WithdrawalCredentials[:vlen]...) + if vlen < 32 { + dst = sszutils.AppendZeroPadding(dst, (32-vlen)*1) + } + } + { // Static Field #2 'Amount' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Amount)) + } + { // Static Field #3 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderDepositRequest from SSZ-encoded bytes. +func (t *BuilderDepositRequest) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 184 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 184) + } + if buflen > 184 { + return sszutils.ErrTrailingDataFn(buflen - 184) + } + { // Field #0 'Pubkey' (static) + buf := buf[0:48] + copy(t.Pubkey[:], buf) + } + { // Field #1 'WithdrawalCredentials' (static) + buf := buf[48:80] + t.WithdrawalCredentials = sszutils.ExpandSlice(t.WithdrawalCredentials, 32) + copy(t.WithdrawalCredentials[:], buf) + } + { // Field #2 'Amount' (static) + buf := buf[80:88] + t.Amount = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'Signature' (static) + buf := buf[88:184] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderDepositRequest. +func (t *BuilderDepositRequest) SizeSSZ() (size int) { + return 184 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderDepositRequest. +func (t *BuilderDepositRequest) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderDepositRequest using the given hash walker. +func (t *BuilderDepositRequest) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderDepositRequest) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Pubkey' + hh.PutBytes(t.Pubkey[:48]) + } + { // Field #1 'WithdrawalCredentials' + vlen := len(t.WithdrawalCredentials) + if vlen > 32 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 32), "WithdrawalCredentials") + } + val := t.WithdrawalCredentials[:] + if vlen < 32 { + val = sszutils.AppendZeroPadding(val, (32-vlen)*1) + } + hh.PutBytes(val[:32]) + } + { // Field #2 'Amount' + hh.PutUint64(uint64(t.Amount)) + } + { // Field #3 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderdepositrequest_yaml.go b/spec/gloas/builderdepositrequest_yaml.go new file mode 100644 index 000000000..27d3ef4ef --- /dev/null +++ b/spec/gloas/builderdepositrequest_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "fmt" + + "github.com/goccy/go-yaml" +) + +// builderDepositRequestYAML is the spec representation of the struct. +type builderDepositRequestYAML struct { + Pubkey string `yaml:"pubkey"` + WithdrawalCredentials string `yaml:"withdrawal_credentials"` + Amount uint64 `yaml:"amount"` + Signature string `yaml:"signature"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderDepositRequest) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderDepositRequestYAML{ + Pubkey: fmt.Sprintf("%#x", b.Pubkey), + WithdrawalCredentials: fmt.Sprintf("%#x", b.WithdrawalCredentials), + Amount: uint64(b.Amount), + Signature: fmt.Sprintf("%#x", b.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderDepositRequest) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var data builderDepositRequestJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return err + } + + return b.unpack(&data) +} diff --git a/spec/gloas/builderexitrequest.go b/spec/gloas/builderexitrequest.go new file mode 100644 index 000000000..90c2b114d --- /dev/null +++ b/spec/gloas/builderexitrequest.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderExitRequest represents a builder exit request (EIP-8282). +type BuilderExitRequest struct { + SourceAddress bellatrix.ExecutionAddress `ssz-size:"20"` + Pubkey phase0.BLSPubKey `ssz-size:"48"` +} + +// String returns a string version of the structure. +func (b *BuilderExitRequest) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderexitrequest_json.go b/spec/gloas/builderexitrequest_json.go new file mode 100644 index 000000000..2990a72ed --- /dev/null +++ b/spec/gloas/builderexitrequest_json.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderExitRequestJSON is the spec representation of the struct. +type builderExitRequestJSON struct { + SourceAddress bellatrix.ExecutionAddress `json:"source_address"` + Pubkey phase0.BLSPubKey `json:"pubkey"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderExitRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderExitRequestJSON{ + SourceAddress: b.SourceAddress, + Pubkey: b.Pubkey, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderExitRequest) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&builderExitRequestJSON{}, input) + if err != nil { + return err + } + + if err := b.SourceAddress.UnmarshalJSON(raw["source_address"]); err != nil { + return errors.Wrap(err, "source_address") + } + + if err := b.Pubkey.UnmarshalJSON(raw["pubkey"]); err != nil { + return errors.Wrap(err, "pubkey") + } + + return nil +} diff --git a/spec/gloas/builderexitrequest_ssz.go b/spec/gloas/builderexitrequest_ssz.go new file mode 100644 index 000000000..5903e3f00 --- /dev/null +++ b/spec/gloas/builderexitrequest_ssz.go @@ -0,0 +1,87 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 6744f46015a151229eea5c50db45670c0ca6575b5ef67d8601cdc56c3d21905b +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderExitRequest](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderExitRequest to SSZ-encoded bytes. +func (t *BuilderExitRequest) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderExitRequest to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderExitRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderExitRequest) + } + { // Static Field #0 'SourceAddress' + dst = append(dst, t.SourceAddress[:20]...) + } + { // Static Field #1 'Pubkey' + dst = append(dst, t.Pubkey[:48]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderExitRequest from SSZ-encoded bytes. +func (t *BuilderExitRequest) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 68 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 68) + } + if buflen > 68 { + return sszutils.ErrTrailingDataFn(buflen - 68) + } + { // Field #0 'SourceAddress' (static) + buf := buf[0:20] + copy(t.SourceAddress[:], buf) + } + { // Field #1 'Pubkey' (static) + buf := buf[20:68] + copy(t.Pubkey[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderExitRequest. +func (t *BuilderExitRequest) SizeSSZ() (size int) { + return 68 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderExitRequest. +func (t *BuilderExitRequest) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderExitRequest using the given hash walker. +func (t *BuilderExitRequest) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderExitRequest) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'SourceAddress' + hh.PutBytes(t.SourceAddress[:20]) + } + { // Field #1 'Pubkey' + hh.PutBytes(t.Pubkey[:48]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderexitrequest_yaml.go b/spec/gloas/builderexitrequest_yaml.go new file mode 100644 index 000000000..5dd0349b8 --- /dev/null +++ b/spec/gloas/builderexitrequest_yaml.go @@ -0,0 +1,60 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// builderExitRequestYAML is the spec representation of the struct. +type builderExitRequestYAML struct { + SourceAddress bellatrix.ExecutionAddress `yaml:"source_address"` + Pubkey phase0.BLSPubKey `yaml:"pubkey"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderExitRequest) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderExitRequestYAML{ + SourceAddress: b.SourceAddress, + Pubkey: b.Pubkey, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderExitRequest) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled builderExitRequestJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(&unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/builderindex.go b/spec/gloas/builderindex.go new file mode 100644 index 000000000..ada3a004b --- /dev/null +++ b/spec/gloas/builderindex.go @@ -0,0 +1,58 @@ +// Copyright © 2022, 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "fmt" + "strconv" + + "github.com/pkg/errors" +) + +// BuilderIndex is the index of the builder. +type BuilderIndex uint64 + +// UnmarshalJSON implements json.Unmarshaler. +func (v *BuilderIndex) UnmarshalJSON(input []byte) error { + if len(input) == 0 { + return errors.New("input missing") + } + + if len(input) < 3 { + return errors.New("input malformed") + } + + if !bytes.HasPrefix(input, []byte{'"'}) { + return errors.New("invalid prefix") + } + + if !bytes.HasSuffix(input, []byte{'"'}) { + return errors.New("invalid suffix") + } + + val, err := strconv.ParseUint(string(input[1:len(input)-1]), 10, 64) + if err != nil { + return errors.Wrapf(err, "invalid value %s", string(input[1:len(input)-1])) + } + + *v = BuilderIndex(val) + + return nil +} + +// MarshalJSON implements json.Marshaler. +func (v BuilderIndex) MarshalJSON() ([]byte, error) { + return fmt.Appendf(nil, `"%d"`, v), nil +} diff --git a/spec/gloas/builderpendingpayment.go b/spec/gloas/builderpendingpayment.go new file mode 100644 index 000000000..bb48ef96e --- /dev/null +++ b/spec/gloas/builderpendingpayment.go @@ -0,0 +1,38 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPendingPayment represents a builder pending payment. +type BuilderPendingPayment struct { + Weight phase0.Gwei + Withdrawal *BuilderPendingWithdrawal + ProposerIndex phase0.ValidatorIndex +} + +// String returns a string version of the structure. +func (p *BuilderPendingPayment) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderpendingpayment_ssz.go b/spec/gloas/builderpendingpayment_ssz.go new file mode 100644 index 000000000..acad482f0 --- /dev/null +++ b/spec/gloas/builderpendingpayment_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 58af077c89deeb7ce2248ff3ff397aff72293d08a25a33a2d0fedf75538f01c9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderPendingPayment](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderPendingPayment to SSZ-encoded bytes. +func (t *BuilderPendingPayment) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPendingPayment to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPendingPayment) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPendingPayment) + } + { // Static Field #0 'Weight' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Weight)) + } + { // Static Field #1 'Withdrawal' + t := t.Withdrawal + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Static Field #2 'ProposerIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposerIndex)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPendingPayment from SSZ-encoded bytes. +func (t *BuilderPendingPayment) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 52 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 52) + } + if buflen > 52 { + return sszutils.ErrTrailingDataFn(buflen - 52) + } + { // Field #0 'Weight' (static) + buf := buf[0:8] + t.Weight = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'Withdrawal' (static) + buf := buf[8:44] + if t.Withdrawal == nil { + t.Withdrawal = new(BuilderPendingWithdrawal) + } + if err = t.Withdrawal.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Field #2 'ProposerIndex' (static) + buf := buf[44:52] + t.ProposerIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPendingPayment. +func (t *BuilderPendingPayment) SizeSSZ() (size int) { + return 52 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPendingPayment. +func (t *BuilderPendingPayment) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPendingPayment using the given hash walker. +func (t *BuilderPendingPayment) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPendingPayment) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Weight' + hh.PutUint64(uint64(t.Weight)) + } + { // Field #1 'Withdrawal' + t := t.Withdrawal + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Field #2 'ProposerIndex' + hh.PutUint64(uint64(t.ProposerIndex)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderpendingwithdrawal.go b/spec/gloas/builderpendingwithdrawal.go new file mode 100644 index 000000000..ee4fca634 --- /dev/null +++ b/spec/gloas/builderpendingwithdrawal.go @@ -0,0 +1,39 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPendingWithdrawal represents a builder pending withdrawal. +type BuilderPendingWithdrawal struct { + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + Amount phase0.Gwei + BuilderIndex BuilderIndex +} + +// String returns a string version of the structure. +func (p *BuilderPendingWithdrawal) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderpendingwithdrawal_ssz.go b/spec/gloas/builderpendingwithdrawal_ssz.go new file mode 100644 index 000000000..f4726a0c8 --- /dev/null +++ b/spec/gloas/builderpendingwithdrawal_ssz.go @@ -0,0 +1,100 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 93a3638d29d1033f5d77582e002a25ce16b7709e67d9a577dca2e2eee05fc03a +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderPendingWithdrawal](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderPendingWithdrawal to SSZ-encoded bytes. +func (t *BuilderPendingWithdrawal) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPendingWithdrawal to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPendingWithdrawal) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPendingWithdrawal) + } + { // Static Field #0 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #1 'Amount' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Amount)) + } + { // Static Field #2 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPendingWithdrawal from SSZ-encoded bytes. +func (t *BuilderPendingWithdrawal) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 36 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 36) + } + if buflen > 36 { + return sszutils.ErrTrailingDataFn(buflen - 36) + } + { // Field #0 'FeeRecipient' (static) + buf := buf[0:20] + copy(t.FeeRecipient[:], buf) + } + { // Field #1 'Amount' (static) + buf := buf[20:28] + t.Amount = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'BuilderIndex' (static) + buf := buf[28:36] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPendingWithdrawal. +func (t *BuilderPendingWithdrawal) SizeSSZ() (size int) { + return 36 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPendingWithdrawal. +func (t *BuilderPendingWithdrawal) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPendingWithdrawal using the given hash walker. +func (t *BuilderPendingWithdrawal) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPendingWithdrawal) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #1 'Amount' + hh.PutUint64(uint64(t.Amount)) + } + { // Field #2 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/consensusspec_test.go b/spec/gloas/consensusspec_test.go new file mode 100644 index 000000000..fbd743145 --- /dev/null +++ b/spec/gloas/consensusspec_test.go @@ -0,0 +1,350 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/golang/snappy" + clone "github.com/huandu/go-clone/generic" + "github.com/pk910/dynamic-ssz/sszutils" + require "github.com/stretchr/testify/require" +) + +// TestConsensusSpec tests the types against the Ethereum consensus spec tests. +func TestConsensusSpec(t *testing.T) { + if os.Getenv("CONSENSUS_SPEC_TESTS_DIR") == "" { + t.Skip("CONSENSUS_SPEC_TESTS_DIR not supplied, not running spec tests") + } + + tests := []struct { + name string + s any + }{ + { + name: "AggregateAndProof", + s: &gloas.AggregateAndProof{}, + }, + { + name: "Attestation", + s: &gloas.Attestation{}, + }, + { + name: "AttestationData", + s: &phase0.AttestationData{}, + }, + { + name: "AttesterSlashing", + s: &gloas.AttesterSlashing{}, + }, + { + name: "BeaconBlock", + s: &gloas.BeaconBlock{}, + }, + { + name: "BeaconBlockBody", + s: &gloas.BeaconBlockBody{}, + }, + { + name: "BeaconBlockHeader", + s: &phase0.BeaconBlockHeader{}, + }, + { + name: "BeaconState", + s: &gloas.BeaconState{}, + }, + { + name: "BLSToExecutionChange", + s: &capella.BLSToExecutionChange{}, + }, + { + name: "Builder", + s: &gloas.Builder{}, + }, + { + name: "BuilderDepositRequest", + s: &gloas.BuilderDepositRequest{}, + }, + { + name: "BuilderExitRequest", + s: &gloas.BuilderExitRequest{}, + }, + { + name: "BuilderPendingPayment", + s: &gloas.BuilderPendingPayment{}, + }, + { + name: "BuilderPendingWithdrawal", + s: &gloas.BuilderPendingWithdrawal{}, + }, + { + name: "Checkpoint", + s: &phase0.Checkpoint{}, + }, + { + name: "ConsolidationRequest", + s: &electra.ConsolidationRequest{}, + }, + { + name: "ContributionAndProof", + s: &altair.ContributionAndProof{}, + }, + { + name: "Deposit", + s: &phase0.Deposit{}, + }, + { + name: "DepositData", + s: &phase0.DepositData{}, + }, + { + name: "DepositMessage", + s: &phase0.DepositMessage{}, + }, + { + name: "DepositRequest", + s: &electra.DepositRequest{}, + }, + { + name: "Eth1Data", + s: &phase0.ETH1Data{}, + }, + { + name: "ExecutionPayload", + s: &gloas.ExecutionPayload{}, + }, + { + name: "ExecutionPayloadBid", + s: &gloas.ExecutionPayloadBid{}, + }, + { + name: "ExecutionPayloadEnvelope", + s: &gloas.ExecutionPayloadEnvelope{}, + }, + { + name: "ExecutionRequests", + s: &gloas.ExecutionRequests{}, + }, + { + name: "Fork", + s: &phase0.Fork{}, + }, + { + name: "ForkData", + s: &phase0.ForkData{}, + }, + { + name: "HistoricalSummary", + s: &capella.HistoricalSummary{}, + }, + { + name: "IndexedAttestation", + s: &gloas.IndexedAttestation{}, + }, + { + name: "IndexedPayloadAttestation", + s: &gloas.IndexedPayloadAttestation{}, + }, + { + name: "PayloadAttestation", + s: &gloas.PayloadAttestation{}, + }, + { + name: "PayloadAttestationData", + s: &gloas.PayloadAttestationData{}, + }, + { + name: "PayloadAttestationMessage", + s: &gloas.PayloadAttestationMessage{}, + }, + { + name: "PendingConsolidation", + s: &electra.PendingConsolidation{}, + }, + { + name: "PendingDeposit", + s: &electra.PendingDeposit{}, + }, + { + name: "PendingPartialWithdrawal", + s: &electra.PendingPartialWithdrawal{}, + }, + { + name: "ProposerPreferences", + s: &gloas.ProposerPreferences{}, + }, + { + name: "ProposerSlashing", + s: &phase0.ProposerSlashing{}, + }, + { + name: "SignedAggregateAndProof", + s: &gloas.SignedAggregateAndProof{}, + }, + { + name: "SignedBeaconBlock", + s: &gloas.SignedBeaconBlock{}, + }, + { + name: "SignedBeaconBlockHeader", + s: &phase0.SignedBeaconBlockHeader{}, + }, + { + name: "SignedBLSToExecutionChange", + s: &capella.SignedBLSToExecutionChange{}, + }, + { + name: "SignedContributionAndProof", + s: &altair.SignedContributionAndProof{}, + }, + { + name: "SignedExecutionPayloadBid", + s: &gloas.SignedExecutionPayloadBid{}, + }, + { + name: "SignedExecutionPayloadEnvelope", + s: &gloas.SignedExecutionPayloadEnvelope{}, + }, + { + name: "SignedProposerPreferences", + s: &gloas.SignedProposerPreferences{}, + }, + { + name: "SignedVoluntaryExit", + s: &phase0.SignedVoluntaryExit{}, + }, + { + name: "SingleAttestation", + s: &electra.SingleAttestation{}, + }, + { + name: "SyncAggregate", + s: &altair.SyncAggregate{}, + }, + { + name: "SyncAggregatorSelectionData", + s: &altair.SyncAggregatorSelectionData{}, + }, + { + name: "SyncCommittee", + s: &altair.SyncCommittee{}, + }, + { + name: "SyncCommitteeContribution", + s: &altair.SyncCommitteeContribution{}, + }, + { + name: "SyncCommitteeMessage", + s: &altair.SyncCommitteeMessage{}, + }, + { + name: "Validator", + s: &phase0.Validator{}, + }, + { + name: "VoluntaryExit", + s: &phase0.VoluntaryExit{}, + }, + { + name: "Withdrawal", + s: &capella.Withdrawal{}, + }, + { + name: "WithdrawalRequest", + s: &electra.WithdrawalRequest{}, + }, + } + + baseDir := filepath.Join(os.Getenv("CONSENSUS_SPEC_TESTS_DIR"), "tests", "mainnet", "gloas", "ssz_static") + for _, test := range tests { + dir := filepath.Join(baseDir, test.name, "ssz_random") + require.NoError(t, filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if path == dir { + // Only interested in subdirectories. + return nil + } + require.NoError(t, err) + if info.IsDir() { + t.Run(fmt.Sprintf("%s/%s", test.name, info.Name()), func(t *testing.T) { + s1 := clone.Clone(test.s) + // Obtain the struct from the YAML. + specYAML, err := os.ReadFile(filepath.Join(path, "value.yaml")) + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(specYAML, s1)) + // Confirm we can return to the YAML. + remarshalledSpecYAML, err := yaml.Marshal(s1) + require.NoError(t, err) + require.YAMLEq(t, testYAMLFormat(specYAML), testYAMLFormat(remarshalledSpecYAML)) + + // Obtain the struct from the SSZ. + s2 := clone.Clone(test.s) + compressedSpecSSZ, err := os.ReadFile(filepath.Join(path, "serialized.ssz_snappy")) + require.NoError(t, err) + var specSSZ []byte + specSSZ, err = snappy.Decode(specSSZ, compressedSpecSSZ) + require.NoError(t, err) + require.NoError(t, s2.(sszutils.FastsszUnmarshaler).UnmarshalSSZ(specSSZ)) + // Confirm we can return to the SSZ. + remarshalledSpecSSZ, err := s2.(sszutils.FastsszMarshaler).MarshalSSZ() + require.NoError(t, err) + require.Equal(t, specSSZ, remarshalledSpecSSZ) + + // Obtain the hash tree root from the YAML. + specYAMLRoot, err := os.ReadFile(filepath.Join(path, "roots.yaml")) + require.NoError(t, err) + // Confirm we calculate the same root. + generatedRootBytes, err := s2.(sszutils.FastsszHashRoot).HashTreeRoot() + require.NoError(t, err) + generatedRoot := fmt.Sprintf("{root: '%#x'}\n", string(generatedRootBytes[:])) + require.YAMLEq(t, string(specYAMLRoot), generatedRoot) + }) + } + + return nil + })) + } +} + +func testYAMLFormat(input []byte) string { + val := make(map[string]any) + if err := yaml.UnmarshalWithOptions(input, &val, yaml.UseOrderedMap()); err != nil { + panic(err) + } + + res, err := yaml.MarshalWithOptions(val, yaml.Flow(true)) + if err != nil { + panic(err) + } + + replacements := [][][]byte{ + {[]byte(`"`), []byte(`'`)}, + // Field 'extra_data' in ExecutionPayloadHeader/case_1 has a non-standard format, fix here. + {[]byte(`extra_data: 0,`), []byte(`extra_data: '0x',`)}, + } + for _, replacement := range replacements { + res = bytes.ReplaceAll(res, replacement[0], replacement[1]) + } + + return string(bytes.ToLower(res)) +} diff --git a/spec/gloas/executionpayload.go b/spec/gloas/executionpayload.go new file mode 100644 index 000000000..20ab67f11 --- /dev/null +++ b/spec/gloas/executionpayload.go @@ -0,0 +1,60 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/holiman/uint256" +) + +// BlockAccessList is the RLP-encoded block access list (EIP-7928). +type BlockAccessList []byte + +// ExecutionPayload represents an execution layer payload. +type ExecutionPayload struct { + ParentHash phase0.Hash32 `ssz-index:"0"` + FeeRecipient bellatrix.ExecutionAddress `ssz-index:"1"` + StateRoot phase0.Root `ssz-index:"2"` + ReceiptsRoot phase0.Root `ssz-index:"3"` + LogsBloom [256]byte `ssz-index:"4"` + PrevRandao [32]byte `ssz-index:"5"` + BlockNumber uint64 `ssz-index:"6"` + GasLimit uint64 `ssz-index:"7"` + GasUsed uint64 `ssz-index:"8"` + Timestamp uint64 `ssz-index:"9"` + ExtraData []byte `dynssz-max:"MAX_EXTRA_DATA_BYTES" ssz-index:"10" ssz-max:"32"` + BaseFeePerGas *uint256.Int `ssz-index:"11" ssz-type:"uint256"` + BlockHash phase0.Hash32 `ssz-index:"12"` + Transactions []bellatrix.Transaction `ssz-index:"13" ssz-type:"progressive-list,progressive-list"` + Withdrawals []*capella.Withdrawal `ssz-index:"14" ssz-type:"progressive-list"` + BlobGasUsed uint64 `ssz-index:"15"` + ExcessBlobGas uint64 `ssz-index:"16"` + BlockAccessList BlockAccessList `ssz-index:"17" ssz-type:"progressive-list"` + SlotNumber uint64 `ssz-index:"18"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayload) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayload_json.go b/spec/gloas/executionpayload_json.go new file mode 100644 index 000000000..f12b9ffc1 --- /dev/null +++ b/spec/gloas/executionpayload_json.go @@ -0,0 +1,311 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/pkg/errors" +) + +// executionPayloadJSON is the spec representation of the struct. +type executionPayloadJSON struct { + ParentHash phase0.Hash32 `json:"parent_hash"` + FeeRecipient bellatrix.ExecutionAddress `json:"fee_recipient"` + StateRoot phase0.Root `json:"state_root"` + ReceiptsRoot phase0.Root `json:"receipts_root"` + LogsBloom string `json:"logs_bloom"` + PrevRandao string `json:"prev_randao"` + BlockNumber string `json:"block_number"` + GasLimit string `json:"gas_limit"` + GasUsed string `json:"gas_used"` + Timestamp string `json:"timestamp"` + ExtraData string `json:"extra_data"` + BaseFeePerGas string `json:"base_fee_per_gas"` + BlockHash phase0.Hash32 `json:"block_hash"` + Transactions []string `json:"transactions"` + Withdrawals []*capella.Withdrawal `json:"withdrawals"` + BlobGasUsed string `json:"blob_gas_used"` + ExcessBlobGas string `json:"excess_blob_gas"` + BlockAccessList string `json:"block_access_list"` + SlotNumber string `json:"slot_number"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayload) MarshalJSON() ([]byte, error) { + transactions := make([]string, len(e.Transactions)) + for i := range e.Transactions { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } + + extraData := "0x" + if len(e.ExtraData) > 0 { + extraData = fmt.Sprintf("%#x", e.ExtraData) + } + + blockAccessList := "0x" + if len(e.BlockAccessList) > 0 { + blockAccessList = fmt.Sprintf("%#x", e.BlockAccessList) + } + + return json.Marshal(&executionPayloadJSON{ + ParentHash: e.ParentHash, + FeeRecipient: e.FeeRecipient, + StateRoot: e.StateRoot, + ReceiptsRoot: e.ReceiptsRoot, + LogsBloom: fmt.Sprintf("%#x", e.LogsBloom), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + BlockNumber: strconv.FormatUint(e.BlockNumber, 10), + GasLimit: strconv.FormatUint(e.GasLimit, 10), + GasUsed: strconv.FormatUint(e.GasUsed, 10), + Timestamp: strconv.FormatUint(e.Timestamp, 10), + ExtraData: extraData, + BaseFeePerGas: e.BaseFeePerGas.Dec(), + BlockHash: e.BlockHash, + Transactions: transactions, + Withdrawals: e.Withdrawals, + BlobGasUsed: strconv.FormatUint(e.BlobGasUsed, 10), + ExcessBlobGas: strconv.FormatUint(e.ExcessBlobGas, 10), + BlockAccessList: blockAccessList, + SlotNumber: strconv.FormatUint(e.SlotNumber, 10), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo +func (e *ExecutionPayload) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&executionPayloadJSON{}, input) + if err != nil { + return err + } + + if err := e.ParentHash.UnmarshalJSON(raw["parent_hash"]); err != nil { + return errors.Wrap(err, "parent_hash") + } + + if err := e.FeeRecipient.UnmarshalJSON(raw["fee_recipient"]); err != nil { + return errors.Wrap(err, "fee_recipient") + } + + if err := e.StateRoot.UnmarshalJSON(raw["state_root"]); err != nil { + return errors.Wrap(err, "state_root") + } + + if err := e.ReceiptsRoot.UnmarshalJSON(raw["receipts_root"]); err != nil { + return errors.Wrap(err, "receipts_root") + } + + logsBloom := raw["logs_bloom"] + if !bytes.HasPrefix(logsBloom, []byte{'"', '0', 'x'}) { + return errors.New("logs_bloom: invalid prefix") + } + + if !bytes.HasSuffix(logsBloom, []byte{'"'}) { + return errors.New("logs_bloom: invalid suffix") + } + + if len(logsBloom) != 1+2+256*2+1 { + return errors.New("logs_bloom: incorrect length") + } + + length, err := hex.Decode(e.LogsBloom[:], logsBloom[3:3+256*2]) + if err != nil { + return errors.Wrap(err, "logs_bloom") + } + + if length != 256 { + return errors.New("logs_bloom: incorrect length") + } + + prevRandao := raw["prev_randao"] + if !bytes.HasPrefix(prevRandao, []byte{'"', '0', 'x'}) { + return errors.New("prev_randao: invalid prefix") + } + + if !bytes.HasSuffix(prevRandao, []byte{'"'}) { + return errors.New("prev_randao: invalid suffix") + } + + if len(prevRandao) != 1+2+32*2+1 { + return errors.New("prev_randao: incorrect length") + } + + length, err = hex.Decode(e.PrevRandao[:], prevRandao[3:3+32*2]) + if err != nil { + return errors.Wrap(err, "prev_randao") + } + + if length != 32 { + return errors.New("prev_randao: incorrect length") + } + + tmpUint, err := strconv.ParseUint(string(bytes.Trim(raw["block_number"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "block_number") + } + + e.BlockNumber = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["gas_limit"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "gas_limit") + } + + e.GasLimit = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["gas_used"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "gas_used") + } + + e.GasUsed = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["timestamp"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "timestamp") + } + + e.Timestamp = tmpUint + + var tmpBytes []byte + + switch { + case bytes.Equal(raw["extra_data"], []byte{'0', 'x'}), bytes.Equal(raw["extra_data"], []byte{'0'}): + // Empty. + default: + tmpBytes = bytes.TrimPrefix(bytes.Trim(raw["extra_data"], `"`), []byte{'0', 'x'}) + if len(tmpBytes)%2 == 1 { + tmpBytes = fmt.Appendf(nil, "0%s", string(tmpBytes)) + } + + tmp, err := hex.DecodeString(string(tmpBytes)) + if err != nil { + return errors.Wrap(err, "extra_data") + } + + if len(tmp) > 32 { + return errors.New("extra_data: incorrect length") + } + + e.ExtraData = tmp + } + + tmpBytes = bytes.Trim(raw["base_fee_per_gas"], `"`) + + tmpBytes = bytes.TrimPrefix(tmpBytes, []byte{'0', 'x'}) + if bytes.HasPrefix(tmpBytes, []byte{'0', 'x'}) { + e.BaseFeePerGas, err = uint256.FromHex(string(tmpBytes)) + } else { + e.BaseFeePerGas, err = uint256.FromDecimal(string(tmpBytes)) + } + + if err != nil { + return errors.Wrap(err, "base_fee_per_gas") + } + + if err := e.BlockHash.UnmarshalJSON(raw["block_hash"]); err != nil { + return errors.Wrap(err, "block_hash") + } + + transactions := make([]json.RawMessage, 0) + if err := json.Unmarshal(raw["transactions"], &transactions); err != nil { + return errors.Wrap(err, "transactions") + } + + if len(transactions) > bellatrix.MaxTransactionsPerPayload { + return errors.Wrap(err, "incorrect length for transactions") + } + + e.Transactions = make([]bellatrix.Transaction, len(transactions)) + for i := range transactions { + if len(transactions[i]) == 0 || + bytes.Equal(transactions[i], []byte{'"', '"'}) || + bytes.Equal(transactions[i], []byte{'"', '0', 'x', '"'}) { + return fmt.Errorf("transaction %d: missing", i) + } + + e.Transactions[i] = make([]byte, (len(transactions[i])-4)/2) + if err := json.Unmarshal(transactions[i], &e.Transactions[i]); err != nil { + return errors.Wrapf(err, "transaction %d", i) + } + + if len(e.Transactions[i]) > bellatrix.MaxBytesPerTransaction { + return errors.Wrapf(err, "incorrect length for transaction %d", i) + } + } + + if err := json.Unmarshal(raw["withdrawals"], &e.Withdrawals); err != nil { + return errors.Wrap(err, "withdrawals") + } + + for i := range e.Withdrawals { + if e.Withdrawals[i] == nil { + return fmt.Errorf("withdrawals entry %d missing", i) + } + } + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["blob_gas_used"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "blob_gas_used") + } + + e.BlobGasUsed = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["excess_blob_gas"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "excess_blob_gas") + } + + e.ExcessBlobGas = tmpUint + + switch { + case bytes.Equal(raw["block_access_list"], []byte{'0', 'x'}), bytes.Equal(raw["block_access_list"], []byte{'0'}): + // Empty. + default: + tmpBytes = bytes.TrimPrefix(bytes.Trim(raw["block_access_list"], `"`), []byte{'0', 'x'}) + if len(tmpBytes)%2 == 1 { + tmpBytes = fmt.Appendf(nil, "0%s", string(tmpBytes)) + } + + tmp, err := hex.DecodeString(string(tmpBytes)) + if err != nil { + return errors.Wrap(err, "block_access_list") + } + + if len(tmp) > bellatrix.MaxBytesPerTransaction { + return errors.New("block_access_list: incorrect length") + } + + e.BlockAccessList = tmp + } + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["slot_number"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "slot_number") + } + + e.SlotNumber = tmpUint + + return nil +} diff --git a/spec/gloas/executionpayload_ssz.go b/spec/gloas/executionpayload_ssz.go new file mode 100644 index 000000000..35ba6686c --- /dev/null +++ b/spec/gloas/executionpayload_ssz.go @@ -0,0 +1,460 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 81e5a47d626f6521c02c01a2b484460fd986437545dc852251014056c19596d0 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayload](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayload to SSZ-encoded bytes. +func (t *ExecutionPayload) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayload to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayload) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayload) + } + dstlen := len(dst) + { // Static Field #0 'ParentHash' + dst = append(dst, t.ParentHash[:32]...) + } + { // Static Field #1 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #2 'StateRoot' + dst = append(dst, t.StateRoot[:32]...) + } + { // Static Field #3 'ReceiptsRoot' + dst = append(dst, t.ReceiptsRoot[:32]...) + } + { // Static Field #4 'LogsBloom' + dst = append(dst, t.LogsBloom[:256]...) + } + { // Static Field #5 'PrevRandao' + dst = append(dst, t.PrevRandao[:32]...) + } + { // Static Field #6 'BlockNumber' + dst = binary.LittleEndian.AppendUint64(dst, t.BlockNumber) + } + { // Static Field #7 'GasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.GasLimit) + } + { // Static Field #8 'GasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.GasUsed) + } + { // Static Field #9 'Timestamp' + dst = binary.LittleEndian.AppendUint64(dst, t.Timestamp) + } + // Offset Field #10 'ExtraData' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #11 'BaseFeePerGas' + t := t.BaseFeePerGas + if t == nil { + t = new(uint256.Int) + } + dst = sszutils.MarshalUint64Slice(dst, (*t)[:4]) + } + { // Static Field #12 'BlockHash' + dst = append(dst, t.BlockHash[:32]...) + } + // Offset Field #13 'Transactions' + // Offset Field #14 'Withdrawals' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #15 'BlobGasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.BlobGasUsed) + } + { // Static Field #16 'ExcessBlobGas' + dst = binary.LittleEndian.AppendUint64(dst, t.ExcessBlobGas) + } + // Offset Field #17 'BlockAccessList' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #18 'SlotNumber' + dst = binary.LittleEndian.AppendUint64(dst, t.SlotNumber) + } + { // Dynamic Field #10 'ExtraData' + binary.LittleEndian.PutUint32(dst[dstlen+436:], uint32(len(dst)-dstlen)) + vlen := len(t.ExtraData) + if vlen > 32 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 32), "ExtraData") + } + dst = append(dst, t.ExtraData[:]...) + } + { // Dynamic Field #13 'Transactions' + binary.LittleEndian.PutUint32(dst[dstlen+504:], uint32(len(dst)-dstlen)) + t := t.Transactions + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + dst = append(dst, t[:]...) + } + } + { // Dynamic Field #14 'Withdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+508:], uint32(len(dst)-dstlen)) + t := t.Withdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #17 'BlockAccessList' + binary.LittleEndian.PutUint32(dst[dstlen+528:], uint32(len(dst)-dstlen)) + t := t.BlockAccessList + dst = append(dst, t[:]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayload from SSZ-encoded bytes. +func (t *ExecutionPayload) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 540 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 540) + } + { // Field #0 'ParentHash' (static) + buf := buf[0:32] + copy(t.ParentHash[:], buf) + } + { // Field #1 'FeeRecipient' (static) + buf := buf[32:52] + copy(t.FeeRecipient[:], buf) + } + { // Field #2 'StateRoot' (static) + buf := buf[52:84] + copy(t.StateRoot[:], buf) + } + { // Field #3 'ReceiptsRoot' (static) + buf := buf[84:116] + copy(t.ReceiptsRoot[:], buf) + } + { // Field #4 'LogsBloom' (static) + buf := buf[116:372] + copy(t.LogsBloom[:], buf) + } + { // Field #5 'PrevRandao' (static) + buf := buf[372:404] + copy(t.PrevRandao[:], buf) + } + { // Field #6 'BlockNumber' (static) + buf := buf[404:412] + t.BlockNumber = binary.LittleEndian.Uint64(buf) + } + { // Field #7 'GasLimit' (static) + buf := buf[412:420] + t.GasLimit = binary.LittleEndian.Uint64(buf) + } + { // Field #8 'GasUsed' (static) + buf := buf[420:428] + t.GasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #9 'Timestamp' (static) + buf := buf[428:436] + t.Timestamp = binary.LittleEndian.Uint64(buf) + } + // Field #10 'ExtraData' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[436:440])) + if offset10 != 540 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset10, 540), "ExtraData:o") + } + { // Field #11 'BaseFeePerGas' (static) + buf := buf[440:472] + val1 := t.BaseFeePerGas + if val1 == nil { + val1 = new(uint256.Int) + } + sszutils.UnmarshalUint64Slice((*val1)[:4], buf) + t.BaseFeePerGas = val1 + } + { // Field #12 'BlockHash' (static) + buf := buf[472:504] + copy(t.BlockHash[:], buf) + } + // Field #13 'Transactions' (offset) + offset13 := int(binary.LittleEndian.Uint32(buf[504:508])) + if offset13 < offset10 || offset13 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset13, offset10, buflen), "Transactions:o") + } + // Field #14 'Withdrawals' (offset) + offset14 := int(binary.LittleEndian.Uint32(buf[508:512])) + if offset14 < offset13 || offset14 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset14, offset13, buflen), "Withdrawals:o") + } + { // Field #15 'BlobGasUsed' (static) + buf := buf[512:520] + t.BlobGasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #16 'ExcessBlobGas' (static) + buf := buf[520:528] + t.ExcessBlobGas = binary.LittleEndian.Uint64(buf) + } + // Field #17 'BlockAccessList' (offset) + offset17 := int(binary.LittleEndian.Uint32(buf[528:532])) + if offset17 < offset14 || offset17 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset17, offset14, buflen), "BlockAccessList:o") + } + { // Field #18 'SlotNumber' (static) + buf := buf[532:540] + t.SlotNumber = binary.LittleEndian.Uint64(buf) + } + { // Field #10 'ExtraData' (dynamic) + buf := buf[offset10:offset13] + if len(buf) > 32 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 32), "ExtraData") + } + t.ExtraData = sszutils.ExpandSlice(t.ExtraData, len(buf)) + copy(t.ExtraData[:], buf) + } + { // Field #13 'Transactions' (dynamic) + buf := buf[offset13:offset14] + val2 := t.Transactions + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "Transactions") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "Transactions") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "Transactions[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val3 := val2[idx1] + val3 = sszutils.ExpandSlice(val3, len(buf)) + copy(val3[:], buf) + val2[idx1] = val3 + } + t.Transactions = val2 + } + { // Field #14 'Withdrawals' (dynamic) + buf := buf[offset14:offset17] + val4 := t.Withdrawals + itemCount := len(buf) / 44 + if len(buf)%44 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 44), "Withdrawals") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + if val4[idx1] == nil { + val4[idx1] = new(capella.Withdrawal) + } + buf := buf[44*idx1 : 44*(idx1+1)] + if err = val4[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + t.Withdrawals = val4 + } + { // Field #17 'BlockAccessList' (dynamic) + buf := buf[offset17:] + val5 := t.BlockAccessList + val5 = sszutils.ExpandSlice(val5, len(buf)) + copy(val5[:], buf) + t.BlockAccessList = val5 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayload. +func (t *ExecutionPayload) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayload) + } + // Field #0 'ParentHash' static (32 bytes) + // Field #1 'FeeRecipient' static (20 bytes) + // Field #2 'StateRoot' static (32 bytes) + // Field #3 'ReceiptsRoot' static (32 bytes) + // Field #4 'LogsBloom' static (256 bytes) + // Field #5 'PrevRandao' static (32 bytes) + // Field #6 'BlockNumber' static (8 bytes) + // Field #7 'GasLimit' static (8 bytes) + // Field #8 'GasUsed' static (8 bytes) + // Field #9 'Timestamp' static (8 bytes) + // Field #10 'ExtraData' offset (4 bytes) + // Field #11 'BaseFeePerGas' static (32 bytes) + // Field #12 'BlockHash' static (32 bytes) + // Field #13 'Transactions' offset (4 bytes) + // Field #14 'Withdrawals' offset (4 bytes) + // Field #15 'BlobGasUsed' static (8 bytes) + // Field #16 'ExcessBlobGas' static (8 bytes) + // Field #17 'BlockAccessList' offset (4 bytes) + // Field #18 'SlotNumber' static (8 bytes) + size += 540 + { // Dynamic field #10 'ExtraData' + size += len(t.ExtraData) + } + { // Dynamic field #13 'Transactions' + t := t.Transactions + vlen := len(t) + size += vlen * 4 // Offsets + for i1 := range vlen { + size += len(t[i1]) + } + } + { // Dynamic field #14 'Withdrawals' + size += len(t.Withdrawals) * 44 + } + { // Dynamic field #17 'BlockAccessList' + size += len(t.BlockAccessList) + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayload. +func (t *ExecutionPayload) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayload using the given hash walker. +func (t *ExecutionPayload) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayload) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ParentHash' + hh.PutBytes(t.ParentHash[:32]) + } + { // Field #1 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #2 'StateRoot' + hh.PutBytes(t.StateRoot[:32]) + } + { // Field #3 'ReceiptsRoot' + hh.PutBytes(t.ReceiptsRoot[:32]) + } + { // Field #4 'LogsBloom' + hh.PutBytes(t.LogsBloom[:256]) + } + { // Field #5 'PrevRandao' + hh.PutBytes(t.PrevRandao[:32]) + } + { // Field #6 'BlockNumber' + hh.PutUint64(t.BlockNumber) + } + { // Field #7 'GasLimit' + hh.PutUint64(t.GasLimit) + } + { // Field #8 'GasUsed' + hh.PutUint64(t.GasUsed) + } + { // Field #9 'Timestamp' + hh.PutUint64(t.Timestamp) + } + { // Field #10 'ExtraData' + vlen := uint64(len(t.ExtraData)) + if vlen > 32 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 32), "ExtraData") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.ExtraData[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(32, vlen, 1)) + } + { // Field #11 'BaseFeePerGas' + t := t.BaseFeePerGas + if t == nil { + t = new(uint256.Int) + } + if root, err := t.HashTreeRoot(); err != nil { + return sszutils.ErrorWithPath(err, "BaseFeePerGas") + } else { + hh.AppendBytes32(root[:]) + } + } + { // Field #12 'BlockHash' + hh.PutBytes(t.BlockHash[:32]) + } + { // Field #13 'Transactions' + t := t.Transactions + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + idx := hh.StartTree(sszutils.TreeTypeProgressive) + hh.AppendBytes32(t[:]) + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #14 'Withdrawals' + t := t.Withdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #15 'BlobGasUsed' + hh.PutUint64(t.BlobGasUsed) + } + { // Field #16 'ExcessBlobGas' + hh.PutUint64(t.ExcessBlobGas) + } + { // Field #17 'BlockAccessList' + t := t.BlockAccessList + idx := hh.StartTree(sszutils.TreeTypeProgressive) + hh.AppendBytes32(t[:]) + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #18 'SlotNumber' + hh.PutUint64(t.SlotNumber) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0xff, 0x07}) + return nil +} diff --git a/spec/gloas/executionpayload_yaml.go b/spec/gloas/executionpayload_yaml.go new file mode 100644 index 000000000..18b220b18 --- /dev/null +++ b/spec/gloas/executionpayload_yaml.go @@ -0,0 +1,109 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// executionPayloadYAML is the spec representation of the struct. +type executionPayloadYAML struct { + ParentHash string `yaml:"parent_hash"` + FeeRecipient string `yaml:"fee_recipient"` + StateRoot string `yaml:"state_root"` + ReceiptsRoot string `yaml:"receipts_root"` + LogsBloom string `yaml:"logs_bloom"` + PrevRandao string `yaml:"prev_randao"` + BlockNumber uint64 `yaml:"block_number"` + GasLimit uint64 `yaml:"gas_limit"` + GasUsed uint64 `yaml:"gas_used"` + Timestamp uint64 `yaml:"timestamp"` + ExtraData string `yaml:"extra_data"` + BaseFeePerGas string `yaml:"base_fee_per_gas"` + BlockHash string `yaml:"block_hash"` + Transactions []string `yaml:"transactions"` + Withdrawals []*capella.Withdrawal `yaml:"withdrawals"` + BlobGasUsed uint64 `yaml:"blob_gas_used"` + ExcessBlobGas uint64 `yaml:"excess_blob_gas"` + BlockAccessList string `yaml:"block_access_list"` + SlotNumber uint64 `yaml:"slot_number"` +} + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayload) MarshalYAML() ([]byte, error) { + transactions := make([]string, len(e.Transactions)) + for i := range e.Transactions { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } + + extraData := "0x" + if len(e.ExtraData) > 0 { + extraData = fmt.Sprintf("%#x", e.ExtraData) + } + + blockAccessList := "0x" + if len(e.BlockAccessList) > 0 { + blockAccessList = fmt.Sprintf("%#x", e.BlockAccessList) + } + + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadYAML{ + ParentHash: e.ParentHash.String(), + FeeRecipient: e.FeeRecipient.String(), + StateRoot: e.StateRoot.String(), + ReceiptsRoot: e.ReceiptsRoot.String(), + LogsBloom: fmt.Sprintf("%#x", e.LogsBloom), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + BlockNumber: e.BlockNumber, + GasLimit: e.GasLimit, + GasUsed: e.GasUsed, + Timestamp: e.Timestamp, + ExtraData: extraData, + BaseFeePerGas: e.BaseFeePerGas.Dec(), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + Transactions: transactions, + Withdrawals: e.Withdrawals, + BlobGasUsed: e.BlobGasUsed, + ExcessBlobGas: e.ExcessBlobGas, + BlockAccessList: blockAccessList, + SlotNumber: e.SlotNumber, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayload) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled executionPayloadJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionpayloadbid.go b/spec/gloas/executionpayloadbid.go new file mode 100644 index 000000000..22b3f649a --- /dev/null +++ b/spec/gloas/executionpayloadbid.go @@ -0,0 +1,49 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ExecutionPayloadBid represents an execution payload bid for EIP-7732. +type ExecutionPayloadBid struct { + ParentBlockHash phase0.Hash32 `ssz-index:"0"` + ParentBlockRoot phase0.Root `ssz-index:"1"` + BlockHash phase0.Hash32 `ssz-index:"2"` + PrevRandao phase0.Root `ssz-index:"3"` + FeeRecipient bellatrix.ExecutionAddress `ssz-index:"4"` + GasLimit uint64 `ssz-index:"5"` + BuilderIndex BuilderIndex `ssz-index:"6"` + Slot phase0.Slot `ssz-index:"7"` + Value phase0.Gwei `ssz-index:"8"` + ExecutionPayment phase0.Gwei `ssz-index:"9"` + BlobKZGCommitments []deneb.KZGCommitment `ssz-index:"10" ssz-type:"progressive-list"` + ExecutionRequestsRoot phase0.Root `ssz-index:"11"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadBid) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayloadbid_json.go b/spec/gloas/executionpayloadbid_json.go new file mode 100644 index 000000000..02bc74307 --- /dev/null +++ b/spec/gloas/executionpayloadbid_json.go @@ -0,0 +1,201 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// executionPayloadBidJSON is the spec representation of the struct. +type executionPayloadBidJSON struct { + ParentBlockHash string `json:"parent_block_hash"` + ParentBlockRoot string `json:"parent_block_root"` + BlockHash string `json:"block_hash"` + PrevRandao string `json:"prev_randao"` + FeeRecipient string `json:"fee_recipient"` + GasLimit string `json:"gas_limit"` + BuilderIndex string `json:"builder_index"` + Slot string `json:"slot"` + Value string `json:"value"` + ExecutionPayment string `json:"execution_payment"` + BlobKZGCommitments []string `json:"blob_kzg_commitments"` + ExecutionRequestsRoot string `json:"execution_requests_root"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadBid) MarshalJSON() ([]byte, error) { + blobKZGCommitments := make([]string, len(e.BlobKZGCommitments)) + for i := range e.BlobKZGCommitments { + blobKZGCommitments[i] = fmt.Sprintf("%#x", e.BlobKZGCommitments[i]) + } + + return json.Marshal(&executionPayloadBidJSON{ + ParentBlockHash: fmt.Sprintf("%#x", e.ParentBlockHash), + ParentBlockRoot: fmt.Sprintf("%#x", e.ParentBlockRoot), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + FeeRecipient: fmt.Sprintf("%#x", e.FeeRecipient), + GasLimit: fmt.Sprintf("%d", e.GasLimit), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + Slot: fmt.Sprintf("%d", e.Slot), + Value: fmt.Sprintf("%d", e.Value), + ExecutionPayment: fmt.Sprintf("%d", e.ExecutionPayment), + BlobKZGCommitments: blobKZGCommitments, + ExecutionRequestsRoot: fmt.Sprintf("%#x", e.ExecutionRequestsRoot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { + var data executionPayloadBidJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // Parent block hash + if data.ParentBlockHash == "" { + return errors.New("parent block hash missing") + } + parentBlockHash, err := hex.DecodeString(strings.TrimPrefix(data.ParentBlockHash, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent block hash") + } + copy(e.ParentBlockHash[:], parentBlockHash) + + // Parent block root + if data.ParentBlockRoot == "" { + return errors.New("parent block root missing") + } + parentBlockRoot, err := hex.DecodeString(strings.TrimPrefix(data.ParentBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent block root") + } + copy(e.ParentBlockRoot[:], parentBlockRoot) + + // Block hash + if data.BlockHash == "" { + return errors.New("block hash missing") + } + blockHash, err := hex.DecodeString(strings.TrimPrefix(data.BlockHash, "0x")) + if err != nil { + return errors.Wrap(err, "invalid block hash") + } + copy(e.BlockHash[:], blockHash) + + // Prev randao + if data.PrevRandao == "" { + return errors.New("prev randao missing") + } + prevRandao, err := hex.DecodeString(strings.TrimPrefix(data.PrevRandao, "0x")) + if err != nil { + return errors.Wrap(err, "invalid prev randao") + } + copy(e.PrevRandao[:], prevRandao) + + // Fee recipient + if data.FeeRecipient == "" { + return errors.New("fee recipient missing") + } + feeRecipient, err := hex.DecodeString(strings.TrimPrefix(data.FeeRecipient, "0x")) + if err != nil { + return errors.Wrap(err, "invalid fee recipient") + } + copy(e.FeeRecipient[:], feeRecipient) + + // Gas limit + if data.GasLimit == "" { + return errors.New("gas limit missing") + } + gasLimit, err := strconv.ParseUint(data.GasLimit, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid gas limit") + } + e.GasLimit = gasLimit + + // Builder index + if data.BuilderIndex == "" { + return errors.New("builder index missing") + } + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid builder index") + } + e.BuilderIndex = BuilderIndex(builderIndex) + + // Slot + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + e.Slot = phase0.Slot(slot) + + // Value + if data.Value == "" { + return errors.New("value missing") + } + value, err := strconv.ParseUint(data.Value, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value") + } + e.Value = phase0.Gwei(value) + + // Execution payment + if data.ExecutionPayment == "" { + return errors.New("execution payment missing") + } + executionPayment, err := strconv.ParseUint(data.ExecutionPayment, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid execution payment") + } + e.ExecutionPayment = phase0.Gwei(executionPayment) + + // Blob KZG commitments + if data.BlobKZGCommitments == nil { + data.BlobKZGCommitments = []string{} + } + e.BlobKZGCommitments = make([]deneb.KZGCommitment, len(data.BlobKZGCommitments)) + for i, commitment := range data.BlobKZGCommitments { + commitmentBytes, err := hex.DecodeString(strings.TrimPrefix(commitment, "0x")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid blob KZG commitment %d", i)) + } + if len(commitmentBytes) != deneb.KZGCommitmentLength { + return fmt.Errorf("blob KZG commitment %d has incorrect length %d", i, len(commitmentBytes)) + } + copy(e.BlobKZGCommitments[i][:], commitmentBytes) + } + + // Execution requests root + if data.ExecutionRequestsRoot == "" { + return errors.New("execution requests root missing") + } + executionRequestsRoot, err := hex.DecodeString(strings.TrimPrefix(data.ExecutionRequestsRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid execution requests root") + } + copy(e.ExecutionRequestsRoot[:], executionRequestsRoot) + + return nil +} diff --git a/spec/gloas/executionpayloadbid_ssz.go b/spec/gloas/executionpayloadbid_ssz.go new file mode 100644 index 000000000..f62d36455 --- /dev/null +++ b/spec/gloas/executionpayloadbid_ssz.go @@ -0,0 +1,233 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: c029e3715523491291a5c9fbc094b18145e9ac7d7f446b81b318085036a64b97 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayloadBid](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayloadBid to SSZ-encoded bytes. +func (t *ExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayloadBid to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayloadBid) + } + dstlen := len(dst) + { // Static Field #0 'ParentBlockHash' + dst = append(dst, t.ParentBlockHash[:32]...) + } + { // Static Field #1 'ParentBlockRoot' + dst = append(dst, t.ParentBlockRoot[:32]...) + } + { // Static Field #2 'BlockHash' + dst = append(dst, t.BlockHash[:32]...) + } + { // Static Field #3 'PrevRandao' + dst = append(dst, t.PrevRandao[:32]...) + } + { // Static Field #4 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #5 'GasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.GasLimit) + } + { // Static Field #6 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + { // Static Field #7 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #8 'Value' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Value)) + } + { // Static Field #9 'ExecutionPayment' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ExecutionPayment)) + } + // Offset Field #10 'BlobKZGCommitments' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #11 'ExecutionRequestsRoot' + dst = append(dst, t.ExecutionRequestsRoot[:32]...) + } + { // Dynamic Field #10 'BlobKZGCommitments' + binary.LittleEndian.PutUint32(dst[dstlen+188:], uint32(len(dst)-dstlen)) + t := t.BlobKZGCommitments + vlen := len(t) + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayloadBid from SSZ-encoded bytes. +func (t *ExecutionPayloadBid) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 224 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 224) + } + { // Field #0 'ParentBlockHash' (static) + buf := buf[0:32] + copy(t.ParentBlockHash[:], buf) + } + { // Field #1 'ParentBlockRoot' (static) + buf := buf[32:64] + copy(t.ParentBlockRoot[:], buf) + } + { // Field #2 'BlockHash' (static) + buf := buf[64:96] + copy(t.BlockHash[:], buf) + } + { // Field #3 'PrevRandao' (static) + buf := buf[96:128] + copy(t.PrevRandao[:], buf) + } + { // Field #4 'FeeRecipient' (static) + buf := buf[128:148] + copy(t.FeeRecipient[:], buf) + } + { // Field #5 'GasLimit' (static) + buf := buf[148:156] + t.GasLimit = binary.LittleEndian.Uint64(buf) + } + { // Field #6 'BuilderIndex' (static) + buf := buf[156:164] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #7 'Slot' (static) + buf := buf[164:172] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #8 'Value' (static) + buf := buf[172:180] + t.Value = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #9 'ExecutionPayment' (static) + buf := buf[180:188] + t.ExecutionPayment = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + // Field #10 'BlobKZGCommitments' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[188:192])) + if offset10 != 224 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset10, 224), "BlobKZGCommitments:o") + } + { // Field #11 'ExecutionRequestsRoot' (static) + buf := buf[192:224] + copy(t.ExecutionRequestsRoot[:], buf) + } + { // Field #10 'BlobKZGCommitments' (dynamic) + buf := buf[offset10:] + val1 := t.BlobKZGCommitments + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "BlobKZGCommitments") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.BlobKZGCommitments = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayloadBid. +func (t *ExecutionPayloadBid) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayloadBid) + } + // Field #0 'ParentBlockHash' static (32 bytes) + // Field #1 'ParentBlockRoot' static (32 bytes) + // Field #2 'BlockHash' static (32 bytes) + // Field #3 'PrevRandao' static (32 bytes) + // Field #4 'FeeRecipient' static (20 bytes) + // Field #5 'GasLimit' static (8 bytes) + // Field #6 'BuilderIndex' static (8 bytes) + // Field #7 'Slot' static (8 bytes) + // Field #8 'Value' static (8 bytes) + // Field #9 'ExecutionPayment' static (8 bytes) + // Field #10 'BlobKZGCommitments' offset (4 bytes) + // Field #11 'ExecutionRequestsRoot' static (32 bytes) + size += 224 + { // Dynamic field #10 'BlobKZGCommitments' + size += len(t.BlobKZGCommitments) * 48 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayloadBid. +func (t *ExecutionPayloadBid) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayloadBid using the given hash walker. +func (t *ExecutionPayloadBid) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayloadBid) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ParentBlockHash' + hh.PutBytes(t.ParentBlockHash[:32]) + } + { // Field #1 'ParentBlockRoot' + hh.PutBytes(t.ParentBlockRoot[:32]) + } + { // Field #2 'BlockHash' + hh.PutBytes(t.BlockHash[:32]) + } + { // Field #3 'PrevRandao' + hh.PutBytes(t.PrevRandao[:32]) + } + { // Field #4 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #5 'GasLimit' + hh.PutUint64(t.GasLimit) + } + { // Field #6 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + { // Field #7 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #8 'Value' + hh.PutUint64(uint64(t.Value)) + } + { // Field #9 'ExecutionPayment' + hh.PutUint64(uint64(t.ExecutionPayment)) + } + { // Field #10 'BlobKZGCommitments' + t := t.BlobKZGCommitments + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #11 'ExecutionRequestsRoot' + hh.PutBytes(t.ExecutionRequestsRoot[:32]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0x0f}) + return nil +} diff --git a/spec/gloas/executionpayloadbid_yaml.go b/spec/gloas/executionpayloadbid_yaml.go new file mode 100644 index 000000000..d81917532 --- /dev/null +++ b/spec/gloas/executionpayloadbid_yaml.go @@ -0,0 +1,65 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayloadBid) MarshalYAML() ([]byte, error) { + blobKZGCommitments := make([]string, len(e.BlobKZGCommitments)) + for i := range e.BlobKZGCommitments { + blobKZGCommitments[i] = fmt.Sprintf("%#x", e.BlobKZGCommitments[i]) + } + + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadBidJSON{ + ParentBlockHash: fmt.Sprintf("%#x", e.ParentBlockHash), + ParentBlockRoot: fmt.Sprintf("%#x", e.ParentBlockRoot), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + FeeRecipient: fmt.Sprintf("%#x", e.FeeRecipient), + GasLimit: fmt.Sprintf("%d", e.GasLimit), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + Slot: fmt.Sprintf("%d", e.Slot), + Value: fmt.Sprintf("%d", e.Value), + ExecutionPayment: fmt.Sprintf("%d", e.ExecutionPayment), + BlobKZGCommitments: blobKZGCommitments, + ExecutionRequestsRoot: fmt.Sprintf("%#x", e.ExecutionRequestsRoot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayloadBid) UnmarshalYAML(input []byte) error { + var data executionPayloadBidJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionpayloadenvelope.go b/spec/gloas/executionpayloadenvelope.go new file mode 100644 index 000000000..7a9cc484c --- /dev/null +++ b/spec/gloas/executionpayloadenvelope.go @@ -0,0 +1,40 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ExecutionPayloadEnvelope represents an execution payload envelope. +type ExecutionPayloadEnvelope struct { + Payload *ExecutionPayload `ssz-index:"0"` + ExecutionRequests *ExecutionRequests `ssz-index:"1"` + BuilderIndex BuilderIndex `ssz-index:"2"` + BeaconBlockRoot phase0.Root `ssz-index:"3"` + ParentBeaconBlockRoot phase0.Root `ssz-index:"4"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadEnvelope) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayloadenvelope_json.go b/spec/gloas/executionpayloadenvelope_json.go new file mode 100644 index 000000000..1485b7712 --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_json.go @@ -0,0 +1,91 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/pkg/errors" +) + +// executionPayloadEnvelopeJSON is the spec representation of the struct. +type executionPayloadEnvelopeJSON struct { + Payload *ExecutionPayload `json:"payload"` + ExecutionRequests *ExecutionRequests `json:"execution_requests"` + BuilderIndex string `json:"builder_index"` + BeaconBlockRoot string `json:"beacon_block_root"` + ParentBeaconBlockRoot string `json:"parent_beacon_block_root"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadEnvelope) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadEnvelopeJSON{ + Payload: e.Payload, + ExecutionRequests: e.ExecutionRequests, + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BeaconBlockRoot: fmt.Sprintf("%#x", e.BeaconBlockRoot), + ParentBeaconBlockRoot: fmt.Sprintf("%#x", e.ParentBeaconBlockRoot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { + var data executionPayloadEnvelopeJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Payload == nil { + return errors.New("payload missing") + } + e.Payload = data.Payload + + if data.ExecutionRequests == nil { + return errors.New("execution requests missing") + } + e.ExecutionRequests = data.ExecutionRequests + + if data.BuilderIndex == "" { + return errors.New("builder index missing") + } + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid builder index") + } + e.BuilderIndex = BuilderIndex(builderIndex) + + if data.BeaconBlockRoot == "" { + return errors.New("beacon block root missing") + } + root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid beacon block root") + } + copy(e.BeaconBlockRoot[:], root) + + if data.ParentBeaconBlockRoot == "" { + return errors.New("parent beacon block root missing") + } + parentRoot, err := hex.DecodeString(strings.TrimPrefix(data.ParentBeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent beacon block root") + } + copy(e.ParentBeaconBlockRoot[:], parentRoot) + + return nil +} diff --git a/spec/gloas/executionpayloadenvelope_ssz.go b/spec/gloas/executionpayloadenvelope_ssz.go new file mode 100644 index 000000000..ee715b8ed --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_ssz.go @@ -0,0 +1,181 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: a36cbe07a5d8c38f27b08333e0ea138079daab656596e7d604c7b242cb786667 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayloadEnvelope](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayloadEnvelope to SSZ-encoded bytes. +func (t *ExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayloadEnvelope to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + dstlen := len(dst) + // Offset Field #0 'Payload' + // Offset Field #1 'ExecutionRequests' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #2 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + { // Static Field #3 'BeaconBlockRoot' + dst = append(dst, t.BeaconBlockRoot[:32]...) + } + { // Static Field #4 'ParentBeaconBlockRoot' + dst = append(dst, t.ParentBeaconBlockRoot[:32]...) + } + { // Dynamic Field #0 'Payload' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Payload + if t == nil { + t = new(ExecutionPayload) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Payload") + } + } + { // Dynamic Field #1 'ExecutionRequests' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.ExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayloadEnvelope from SSZ-encoded bytes. +func (t *ExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 80 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 80) + } + // Field #0 'Payload' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 80 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 80), "Payload:o") + } + // Field #1 'ExecutionRequests' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "ExecutionRequests:o") + } + { // Field #2 'BuilderIndex' (static) + buf := buf[8:16] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'BeaconBlockRoot' (static) + buf := buf[16:48] + copy(t.BeaconBlockRoot[:], buf) + } + { // Field #4 'ParentBeaconBlockRoot' (static) + buf := buf[48:80] + copy(t.ParentBeaconBlockRoot[:], buf) + } + { // Field #0 'Payload' (dynamic) + buf := buf[offset0:offset1] + if t.Payload == nil { + t.Payload = new(ExecutionPayload) + } + if err = t.Payload.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Payload") + } + } + { // Field #1 'ExecutionRequests' (dynamic) + buf := buf[offset1:] + if t.ExecutionRequests == nil { + t.ExecutionRequests = new(ExecutionRequests) + } + if err = t.ExecutionRequests.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayloadEnvelope. +func (t *ExecutionPayloadEnvelope) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + // Field #0 'Payload' offset (4 bytes) + // Field #1 'ExecutionRequests' offset (4 bytes) + // Field #2 'BuilderIndex' static (8 bytes) + // Field #3 'BeaconBlockRoot' static (32 bytes) + // Field #4 'ParentBeaconBlockRoot' static (32 bytes) + size += 80 + { // Dynamic field #0 'Payload' + size += t.Payload.SizeSSZ() + } + { // Dynamic field #1 'ExecutionRequests' + size += t.ExecutionRequests.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayloadEnvelope. +func (t *ExecutionPayloadEnvelope) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayloadEnvelope using the given hash walker. +func (t *ExecutionPayloadEnvelope) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Payload' + t := t.Payload + if t == nil { + t = new(ExecutionPayload) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Payload") + } + } + { // Field #1 'ExecutionRequests' + t := t.ExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + { // Field #2 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + { // Field #3 'BeaconBlockRoot' + hh.PutBytes(t.BeaconBlockRoot[:32]) + } + { // Field #4 'ParentBeaconBlockRoot' + hh.PutBytes(t.ParentBeaconBlockRoot[:32]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x1f}) + return nil +} diff --git a/spec/gloas/executionpayloadenvelope_yaml.go b/spec/gloas/executionpayloadenvelope_yaml.go new file mode 100644 index 000000000..88a561e1f --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayloadEnvelope) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadEnvelopeJSON{ + Payload: e.Payload, + ExecutionRequests: e.ExecutionRequests, + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BeaconBlockRoot: fmt.Sprintf("%#x", e.BeaconBlockRoot), + ParentBeaconBlockRoot: fmt.Sprintf("%#x", e.ParentBeaconBlockRoot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayloadEnvelope) UnmarshalYAML(input []byte) error { + var data executionPayloadEnvelopeJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionrequests.go b/spec/gloas/executionrequests.go new file mode 100644 index 000000000..b570df731 --- /dev/null +++ b/spec/gloas/executionrequests.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/goccy/go-yaml" +) + +// ExecutionRequests represents the execution layer triggered requests of a payload (EIP-8282). +type ExecutionRequests struct { + Deposits []*electra.DepositRequest `ssz-index:"0" ssz-type:"progressive-list"` + Withdrawals []*electra.WithdrawalRequest `ssz-index:"1" ssz-type:"progressive-list"` + Consolidations []*electra.ConsolidationRequest `ssz-index:"2" ssz-type:"progressive-list"` + BuilderDeposits []*BuilderDepositRequest `ssz-index:"3" ssz-type:"progressive-list"` + BuilderExits []*BuilderExitRequest `ssz-index:"4" ssz-type:"progressive-list"` +} + +// String returns a string version of the structure. +func (e *ExecutionRequests) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionrequests_json.go b/spec/gloas/executionrequests_json.go new file mode 100644 index 000000000..884d70d72 --- /dev/null +++ b/spec/gloas/executionrequests_json.go @@ -0,0 +1,103 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/pkg/errors" +) + +// executionRequestsJSON is the spec representation of the struct. +type executionRequestsJSON struct { + Deposits []*electra.DepositRequest `json:"deposits"` + Withdrawals []*electra.WithdrawalRequest `json:"withdrawals"` + Consolidations []*electra.ConsolidationRequest `json:"consolidations"` + BuilderDeposits []*BuilderDepositRequest `json:"builder_deposits"` + BuilderExits []*BuilderExitRequest `json:"builder_exits"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionRequests) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionRequestsJSON{ + Deposits: e.Deposits, + Withdrawals: e.Withdrawals, + Consolidations: e.Consolidations, + BuilderDeposits: e.BuilderDeposits, + BuilderExits: e.BuilderExits, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionRequests) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&executionRequestsJSON{}, input) + if err != nil { + return err + } + + if err := json.Unmarshal(raw["deposits"], &e.Deposits); err != nil { + return errors.Wrap(err, "deposits") + } + + for i := range e.Deposits { + if e.Deposits[i] == nil { + return fmt.Errorf("deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["withdrawals"], &e.Withdrawals); err != nil { + return errors.Wrap(err, "withdrawals") + } + + for i := range e.Withdrawals { + if e.Withdrawals[i] == nil { + return fmt.Errorf("withdrawals entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["consolidations"], &e.Consolidations); err != nil { + return errors.Wrap(err, "consolidations") + } + + for i := range e.Consolidations { + if e.Consolidations[i] == nil { + return fmt.Errorf("consolidation requests entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_deposits"], &e.BuilderDeposits); err != nil { + return errors.Wrap(err, "builder_deposits") + } + + for i := range e.BuilderDeposits { + if e.BuilderDeposits[i] == nil { + return fmt.Errorf("builder deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_exits"], &e.BuilderExits); err != nil { + return errors.Wrap(err, "builder_exits") + } + + for i := range e.BuilderExits { + if e.BuilderExits[i] == nil { + return fmt.Errorf("builder exits entry %d missing", i) + } + } + + return nil +} diff --git a/spec/gloas/executionrequests_ssz.go b/spec/gloas/executionrequests_ssz.go new file mode 100644 index 000000000..99d086e7d --- /dev/null +++ b/spec/gloas/executionrequests_ssz.go @@ -0,0 +1,379 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: d23bfbfe177e790f259c72ecceeb7dbb69430c0b7695b247d261574b8958b0f9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/electra" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionRequests](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionRequests to SSZ-encoded bytes. +func (t *ExecutionRequests) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionRequests to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionRequests) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(ExecutionRequests) + } + dstlen := len(dst) + // Offset Field #0 'Deposits' + // Offset Field #1 'Withdrawals' + // Offset Field #2 'Consolidations' + // Offset Field #3 'BuilderDeposits' + // Offset Field #4 'BuilderExits' + dst = append(dst, zeroBytes[:20]...) + { // Dynamic Field #0 'Deposits' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Deposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.DepositRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + } + { // Dynamic Field #1 'Withdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.Withdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.WithdrawalRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #2 'Consolidations' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Consolidations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.ConsolidationRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + } + } + { // Dynamic Field #3 'BuilderDeposits' + binary.LittleEndian.PutUint32(dst[dstlen+12:], uint32(len(dst)-dstlen)) + t := t.BuilderDeposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderDepositRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + } + } + { // Dynamic Field #4 'BuilderExits' + binary.LittleEndian.PutUint32(dst[dstlen+16:], uint32(len(dst)-dstlen)) + t := t.BuilderExits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderExitRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionRequests from SSZ-encoded bytes. +func (t *ExecutionRequests) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 20 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 20) + } + // Field #0 'Deposits' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 20 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 20), "Deposits:o") + } + // Field #1 'Withdrawals' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Withdrawals:o") + } + // Field #2 'Consolidations' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "Consolidations:o") + } + // Field #3 'BuilderDeposits' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[12:16])) + if offset3 < offset2 || offset3 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset3, offset2, buflen), "BuilderDeposits:o") + } + // Field #4 'BuilderExits' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[16:20])) + if offset4 < offset3 || offset4 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset4, offset3, buflen), "BuilderExits:o") + } + { // Field #0 'Deposits' (dynamic) + buf := buf[offset0:offset1] + val1 := t.Deposits + itemCount := len(buf) / 192 + if len(buf)%192 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 192), "Deposits") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + if val1[idx1] == nil { + val1[idx1] = new(electra.DepositRequest) + } + buf := buf[192*idx1 : 192*(idx1+1)] + if err = val1[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + t.Deposits = val1 + } + { // Field #1 'Withdrawals' (dynamic) + buf := buf[offset1:offset2] + val2 := t.Withdrawals + itemCount := len(buf) / 76 + if len(buf)%76 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 76), "Withdrawals") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + if val2[idx1] == nil { + val2[idx1] = new(electra.WithdrawalRequest) + } + buf := buf[76*idx1 : 76*(idx1+1)] + if err = val2[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + t.Withdrawals = val2 + } + { // Field #2 'Consolidations' (dynamic) + buf := buf[offset2:offset3] + val3 := t.Consolidations + itemCount := len(buf) / 116 + if len(buf)%116 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 116), "Consolidations") + } + val3 = sszutils.ExpandSlice(val3, itemCount) + for idx1 := range itemCount { + if val3[idx1] == nil { + val3[idx1] = new(electra.ConsolidationRequest) + } + buf := buf[116*idx1 : 116*(idx1+1)] + if err = val3[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + } + t.Consolidations = val3 + } + { // Field #3 'BuilderDeposits' (dynamic) + buf := buf[offset3:offset4] + val4 := t.BuilderDeposits + itemCount := len(buf) / 184 + if len(buf)%184 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 184), "BuilderDeposits") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + if val4[idx1] == nil { + val4[idx1] = new(BuilderDepositRequest) + } + buf := buf[184*idx1 : 184*(idx1+1)] + if err = val4[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + } + t.BuilderDeposits = val4 + } + { // Field #4 'BuilderExits' (dynamic) + buf := buf[offset4:] + val5 := t.BuilderExits + itemCount := len(buf) / 68 + if len(buf)%68 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 68), "BuilderExits") + } + val5 = sszutils.ExpandSlice(val5, itemCount) + for idx1 := range itemCount { + if val5[idx1] == nil { + val5[idx1] = new(BuilderExitRequest) + } + buf := buf[68*idx1 : 68*(idx1+1)] + if err = val5[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + } + t.BuilderExits = val5 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionRequests. +func (t *ExecutionRequests) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionRequests) + } + // Field #0 'Deposits' offset (4 bytes) + // Field #1 'Withdrawals' offset (4 bytes) + // Field #2 'Consolidations' offset (4 bytes) + // Field #3 'BuilderDeposits' offset (4 bytes) + // Field #4 'BuilderExits' offset (4 bytes) + size += 20 + { // Dynamic field #0 'Deposits' + size += len(t.Deposits) * 192 + } + { // Dynamic field #1 'Withdrawals' + size += len(t.Withdrawals) * 76 + } + { // Dynamic field #2 'Consolidations' + size += len(t.Consolidations) * 116 + } + { // Dynamic field #3 'BuilderDeposits' + size += len(t.BuilderDeposits) * 184 + } + { // Dynamic field #4 'BuilderExits' + size += len(t.BuilderExits) * 68 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionRequests. +func (t *ExecutionRequests) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionRequests using the given hash walker. +func (t *ExecutionRequests) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionRequests) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Deposits' + t := t.Deposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.DepositRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #1 'Withdrawals' + t := t.Withdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.WithdrawalRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #2 'Consolidations' + t := t.Consolidations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.ConsolidationRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #3 'BuilderDeposits' + t := t.BuilderDeposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderDepositRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #4 'BuilderExits' + t := t.BuilderExits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderExitRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x1f}) + return nil +} diff --git a/spec/gloas/executionrequests_yaml.go b/spec/gloas/executionrequests_yaml.go new file mode 100644 index 000000000..fff707118 --- /dev/null +++ b/spec/gloas/executionrequests_yaml.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// executionRequestsYAML is the spec representation of the struct. +type executionRequestsYAML struct { + Deposits []*electra.DepositRequest `yaml:"deposits"` + Withdrawals []*electra.WithdrawalRequest `yaml:"withdrawals"` + Consolidations []*electra.ConsolidationRequest `yaml:"consolidations"` + BuilderDeposits []*BuilderDepositRequest `yaml:"builder_deposits"` + BuilderExits []*BuilderExitRequest `yaml:"builder_exits"` +} + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionRequests) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&executionRequestsYAML{ + Deposits: e.Deposits, + Withdrawals: e.Withdrawals, + Consolidations: e.Consolidations, + BuilderDeposits: e.BuilderDeposits, + BuilderExits: e.BuilderExits, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionRequests) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled executionRequestsJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/generate.go b/spec/gloas/generate.go new file mode 100644 index 000000000..e23b9a9fb --- /dev/null +++ b/spec/gloas/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +//go:generate rm -f *_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/spec/gloas/generate.yaml b/spec/gloas/generate.yaml new file mode 100644 index 000000000..9336ed087 --- /dev/null +++ b/spec/gloas/generate.yaml @@ -0,0 +1,57 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: AggregateAndProof + output: aggregateandproof_ssz.go + - name: Attestation + output: attestation_ssz.go + - name: AttesterSlashing + output: attesterslashing_ssz.go + - name: BeaconBlockBody + output: beaconblockbody_ssz.go + - name: BeaconBlock + output: beaconblock_ssz.go + - name: BeaconState + output: beaconstate_ssz.go + - name: Builder + output: builder_ssz.go + - name: BuilderPendingPayment + output: builderpendingpayment_ssz.go + - name: BuilderPendingWithdrawal + output: builderpendingwithdrawal_ssz.go + - name: BuilderDepositRequest + output: builderdepositrequest_ssz.go + - name: BuilderExitRequest + output: builderexitrequest_ssz.go + - name: ExecutionPayload + output: executionpayload_ssz.go + - name: ExecutionRequests + output: executionrequests_ssz.go + - name: ExecutionPayloadBid + output: executionpayloadbid_ssz.go + - name: ExecutionPayloadEnvelope + output: executionpayloadenvelope_ssz.go + - name: IndexedAttestation + output: indexedattestation_ssz.go + - name: IndexedPayloadAttestation + output: indexedpayloadattestation_ssz.go + - name: PayloadAttestation + output: payloadattestation_ssz.go + - name: PayloadAttestationData + output: payloadattestationdata_ssz.go + - name: PayloadAttestationMessage + output: payloadattestationmessage_ssz.go + - name: ProposerPreferences + output: proposerpreferences_ssz.go + - name: SignedAggregateAndProof + output: signedaggregateandproof_ssz.go + - name: SignedBeaconBlock + output: signedbeaconblock_ssz.go + - name: SignedExecutionPayloadBid + output: signedexecutionpayloadbid_ssz.go + - name: SignedExecutionPayloadEnvelope + output: signedexecutionpayloadenvelope_ssz.go + - name: SignedProposerPreferences + output: signedproposerpreferences_ssz.go diff --git a/spec/gloas/indexedattestation.go b/spec/gloas/indexedattestation.go new file mode 100644 index 000000000..5c30472c8 --- /dev/null +++ b/spec/gloas/indexedattestation.go @@ -0,0 +1,147 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// IndexedAttestation provides a signed attestation with a list of attesting indices. +type IndexedAttestation struct { + // Currently using primitives as sszgen does not handle []ValidatorIndex + AttestingIndices []uint64 `ssz-index:"0" ssz-type:"progressive-list"` + Data *phase0.AttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` +} + +// indexedAttestationJSON is the spec representation of the struct. +type indexedAttestationJSON struct { + AttestingIndices []string `json:"attesting_indices"` + Data *phase0.AttestationData `json:"data"` + Signature string `json:"signature"` +} + +// indexedAttestationYAML is a raw representation of the struct. +type indexedAttestationYAML struct { + AttestingIndices []uint64 `yaml:"attesting_indices"` + Data *phase0.AttestationData `yaml:"data"` + Signature string `yaml:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (i *IndexedAttestation) MarshalJSON() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for j := range i.AttestingIndices { + attestingIndices[j] = strconv.FormatUint(i.AttestingIndices[j], 10) + } + + return json.Marshal(&indexedAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (i *IndexedAttestation) UnmarshalJSON(input []byte) error { + var indexedAttestationJSON indexedAttestationJSON + if err := json.Unmarshal(input, &indexedAttestationJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return i.unpack(&indexedAttestationJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (i *IndexedAttestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&indexedAttestationYAML{ + AttestingIndices: i.AttestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (i *IndexedAttestation) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var indexedAttestationJSON indexedAttestationJSON + if err := yaml.Unmarshal(input, &indexedAttestationJSON); err != nil { + return err + } + + return i.unpack(&indexedAttestationJSON) +} + +// String returns a string version of the structure. +func (i *IndexedAttestation) String() string { + data, err := yaml.Marshal(i) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (i *IndexedAttestation) unpack(indexedAttestationJSON *indexedAttestationJSON) error { + var err error + // 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 { + return errors.Wrap(err, "failed to parse attesting index") + } + } + + if indexedAttestationJSON.Data == nil { + return errors.New("data missing") + } + + i.Data = indexedAttestationJSON.Data + if indexedAttestationJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(indexedAttestationJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(i.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/indexedattestation_ssz.go b/spec/gloas/indexedattestation_ssz.go new file mode 100644 index 000000000..0879cec48 --- /dev/null +++ b/spec/gloas/indexedattestation_ssz.go @@ -0,0 +1,147 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 4333584b59b5cad6eb3c9eeade45c584ec1f19d51a5d74c1efe7aa5a5402e7d8 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[IndexedAttestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *IndexedAttestation to SSZ-encoded bytes. +func (t *IndexedAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *IndexedAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *IndexedAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(IndexedAttestation) + } + dstlen := len(dst) + // Offset Field #0 'AttestingIndices' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'AttestingIndices' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AttestingIndices + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *IndexedAttestation from SSZ-encoded bytes. +func (t *IndexedAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 228 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 228) + } + // Field #0 'AttestingIndices' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 228 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 228), "AttestingIndices:o") + } + { // Field #1 'Data' (static) + buf := buf[4:132] + if t.Data == nil { + t.Data = new(phase0.AttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[132:228] + copy(t.Signature[:], buf) + } + { // Field #0 'AttestingIndices' (dynamic) + buf := buf[offset0:] + val1 := t.AttestingIndices + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "AttestingIndices") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalUint64Slice(val1, buf) + t.AttestingIndices = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *IndexedAttestation. +func (t *IndexedAttestation) SizeSSZ() (size int) { + if t == nil { + t = new(IndexedAttestation) + } + // Field #0 'AttestingIndices' offset (4 bytes) + // Field #1 'Data' static (128 bytes) + // Field #2 'Signature' static (96 bytes) + size += 228 + { // Dynamic field #0 'AttestingIndices' + size += len(t.AttestingIndices) * 8 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *IndexedAttestation. +func (t *IndexedAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *IndexedAttestation using the given hash walker. +func (t *IndexedAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(IndexedAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AttestingIndices' + t := t.AttestingIndices + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/indexedpayloadattestation.go b/spec/gloas/indexedpayloadattestation.go new file mode 100644 index 000000000..2be803ebe --- /dev/null +++ b/spec/gloas/indexedpayloadattestation.go @@ -0,0 +1,38 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// IndexedPayloadAttestation represents an indexed payload attestation. +type IndexedPayloadAttestation struct { + AttestingIndices []phase0.ValidatorIndex `dynssz-max:"PTC_SIZE" ssz-index:"0" ssz-max:"512"` + Data *PayloadAttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2"` +} + +// String returns a string version of the structure. +func (i *IndexedPayloadAttestation) String() string { + data, err := yaml.Marshal(i) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/indexedpayloadattestation_json.go b/spec/gloas/indexedpayloadattestation_json.go new file mode 100644 index 000000000..6781a55e6 --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_json.go @@ -0,0 +1,79 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// indexedPayloadAttestationJSON is the spec representation of the struct. +type indexedPayloadAttestationJSON struct { + AttestingIndices []string `json:"attesting_indices"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (i *IndexedPayloadAttestation) MarshalJSON() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for index := range i.AttestingIndices { + attestingIndices[index] = fmt.Sprintf("%d", i.AttestingIndices[index]) + } + + return json.Marshal(&indexedPayloadAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (i *IndexedPayloadAttestation) UnmarshalJSON(input []byte) error { + var data indexedPayloadAttestationJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + i.AttestingIndices = make([]phase0.ValidatorIndex, len(data.AttestingIndices)) + for index := range data.AttestingIndices { + validatorIndex, err := strconv.ParseUint(data.AttestingIndices[index], 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + i.AttestingIndices[index] = phase0.ValidatorIndex(validatorIndex) + } + + if data.Data == nil { + return errors.New("data missing") + } + i.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(i.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/indexedpayloadattestation_ssz.go b/spec/gloas/indexedpayloadattestation_ssz.go new file mode 100644 index 000000000..264986bb4 --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_ssz.go @@ -0,0 +1,155 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: e5106e1b343855cf77f85e7f88bdd3b5a8e65aaa84980b9159905404a08de64f +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[IndexedPayloadAttestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *IndexedPayloadAttestation to SSZ-encoded bytes. +func (t *IndexedPayloadAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *IndexedPayloadAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *IndexedPayloadAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(IndexedPayloadAttestation) + } + dstlen := len(dst) + // Offset Field #0 'AttestingIndices' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'AttestingIndices' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AttestingIndices + vlen := len(t) + if vlen > 512 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 512), "AttestingIndices") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *IndexedPayloadAttestation from SSZ-encoded bytes. +func (t *IndexedPayloadAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 142 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 142) + } + // Field #0 'AttestingIndices' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 142 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 142), "AttestingIndices:o") + } + { // Field #1 'Data' (static) + buf := buf[4:46] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[46:142] + copy(t.Signature[:], buf) + } + { // Field #0 'AttestingIndices' (dynamic) + buf := buf[offset0:] + val1 := t.AttestingIndices + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "AttestingIndices") + } + if itemCount > 512 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 512), "AttestingIndices") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalUint64Slice(val1, buf) + t.AttestingIndices = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *IndexedPayloadAttestation. +func (t *IndexedPayloadAttestation) SizeSSZ() (size int) { + if t == nil { + t = new(IndexedPayloadAttestation) + } + // Field #0 'AttestingIndices' offset (4 bytes) + // Field #1 'Data' static (42 bytes) + // Field #2 'Signature' static (96 bytes) + size += 142 + { // Dynamic field #0 'AttestingIndices' + size += len(t.AttestingIndices) * 8 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *IndexedPayloadAttestation. +func (t *IndexedPayloadAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *IndexedPayloadAttestation using the given hash walker. +func (t *IndexedPayloadAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(IndexedPayloadAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AttestingIndices' + t := t.AttestingIndices + vlen := uint64(len(t)) + if vlen > 512 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 512), "AttestingIndices") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(512, vlen, 8)) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/indexedpayloadattestation_yaml.go b/spec/gloas/indexedpayloadattestation_yaml.go new file mode 100644 index 000000000..ed540e283 --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (i *IndexedPayloadAttestation) MarshalYAML() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for index := range i.AttestingIndices { + attestingIndices[index] = fmt.Sprintf("%d", i.AttestingIndices[index]) + } + yamlBytes, err := yaml.MarshalWithOptions(&indexedPayloadAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (i *IndexedPayloadAttestation) UnmarshalYAML(input []byte) error { + var data indexedPayloadAttestationJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return i.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/payloadattestation.go b/spec/gloas/payloadattestation.go new file mode 100644 index 000000000..88f2d0d7f --- /dev/null +++ b/spec/gloas/payloadattestation.go @@ -0,0 +1,39 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestation represents a payload attestation. +type PayloadAttestation struct { + AggregationBits bitfield.Bitvector512 `dynssz-size:"PTC_SIZE/8" ssz-index:"0" ssz-size:"64"` + Data *PayloadAttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2"` +} + +// String returns a string version of the structure. +func (p *PayloadAttestation) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestation_json.go b/spec/gloas/payloadattestation_json.go new file mode 100644 index 000000000..e94c67d74 --- /dev/null +++ b/spec/gloas/payloadattestation_json.go @@ -0,0 +1,72 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// payloadAttestationJSON is the spec representation of the struct. +type payloadAttestationJSON struct { + AggregationBits string `json:"aggregation_bits"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestation) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationJSON{ + AggregationBits: fmt.Sprintf("%#x", p.AggregationBits), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestation) UnmarshalJSON(input []byte) error { + var data payloadAttestationJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.AggregationBits == "" { + return errors.New("aggregation bits missing") + } + bits, err := hex.DecodeString(strings.TrimPrefix(data.AggregationBits, "0x")) + if err != nil { + return errors.Wrap(err, "invalid aggregation bits") + } + p.AggregationBits = bits + + if data.Data == nil { + return errors.New("data missing") + } + p.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(p.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/payloadattestation_ssz.go b/spec/gloas/payloadattestation_ssz.go new file mode 100644 index 000000000..5431ea20a --- /dev/null +++ b/spec/gloas/payloadattestation_ssz.go @@ -0,0 +1,130 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 6e7bc3ef07452a47ae01e1a88fbfe02bd3f61e8392b5fa4ab997c9e81569bfa6 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestation](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestation to SSZ-encoded bytes. +func (t *PayloadAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestation) + } + { // Static Field #0 'AggregationBits' + vlen := len(t.AggregationBits) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "AggregationBits") + } + dst = append(dst, t.AggregationBits[:vlen]...) + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*1) + } + } + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestation from SSZ-encoded bytes. +func (t *PayloadAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 202 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 202) + } + if buflen > 202 { + return sszutils.ErrTrailingDataFn(buflen - 202) + } + { // Field #0 'AggregationBits' (static) + buf := buf[0:64] + t.AggregationBits = sszutils.ExpandSlice(t.AggregationBits, 64) + copy(t.AggregationBits[:], buf) + } + { // Field #1 'Data' (static) + buf := buf[64:106] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[106:202] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestation. +func (t *PayloadAttestation) SizeSSZ() (size int) { + return 202 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestation. +func (t *PayloadAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestation using the given hash walker. +func (t *PayloadAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregationBits' + vlen := len(t.AggregationBits) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "AggregationBits") + } + val := t.AggregationBits[:] + if vlen < 64 { + val = sszutils.AppendZeroPadding(val, (64-vlen)*1) + } + hh.PutBytes(val[:64]) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/payloadattestation_yaml.go b/spec/gloas/payloadattestation_yaml.go new file mode 100644 index 000000000..3056bbb16 --- /dev/null +++ b/spec/gloas/payloadattestation_yaml.go @@ -0,0 +1,51 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationJSON{ + AggregationBits: fmt.Sprintf("%#x", p.AggregationBits), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestation) UnmarshalYAML(input []byte) error { + var data payloadAttestationJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/payloadattestationdata.go b/spec/gloas/payloadattestationdata.go new file mode 100644 index 000000000..fbcde0b26 --- /dev/null +++ b/spec/gloas/payloadattestationdata.go @@ -0,0 +1,39 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestationData represents payload attestation data. +type PayloadAttestationData struct { + BeaconBlockRoot phase0.Root `ssz-size:"32"` + Slot phase0.Slot + PayloadPresent bool + BlobDataAvailable bool +} + +// String returns a string version of the structure. +func (p *PayloadAttestationData) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestationdata_json.go b/spec/gloas/payloadattestationdata_json.go new file mode 100644 index 000000000..7235098e1 --- /dev/null +++ b/spec/gloas/payloadattestationdata_json.go @@ -0,0 +1,72 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// payloadAttestationDataJSON is the spec representation of the struct. +type payloadAttestationDataJSON struct { + BeaconBlockRoot string `json:"beacon_block_root"` + Slot string `json:"slot"` + PayloadPresent bool `json:"payload_present"` + BlobDataAvailable bool `json:"blob_data_available"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestationData) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationDataJSON{ + BeaconBlockRoot: fmt.Sprintf("%#x", p.BeaconBlockRoot), + Slot: fmt.Sprintf("%d", p.Slot), + PayloadPresent: p.PayloadPresent, + BlobDataAvailable: p.BlobDataAvailable, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestationData) UnmarshalJSON(input []byte) error { + var data payloadAttestationDataJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.BeaconBlockRoot == "" { + return errors.New("beacon block root missing") + } + root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid beacon block root") + } + copy(p.BeaconBlockRoot[:], root) + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + p.Slot = phase0.Slot(slot) + + p.PayloadPresent = data.PayloadPresent + + p.BlobDataAvailable = data.BlobDataAvailable + + return nil +} diff --git a/spec/gloas/payloadattestationdata_ssz.go b/spec/gloas/payloadattestationdata_ssz.go new file mode 100644 index 000000000..0ce14d827 --- /dev/null +++ b/spec/gloas/payloadattestationdata_ssz.go @@ -0,0 +1,116 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: bc7c6dc18dab78bc139b97fbd8777cc5cc5aa36fd9e267e054cad05789205c6d +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestationData](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestationData to SSZ-encoded bytes. +func (t *PayloadAttestationData) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestationData to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestationData) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestationData) + } + { // Static Field #0 'BeaconBlockRoot' + dst = append(dst, t.BeaconBlockRoot[:32]...) + } + { // Static Field #1 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #2 'PayloadPresent' + dst = sszutils.MarshalBool(dst, t.PayloadPresent) + } + { // Static Field #3 'BlobDataAvailable' + dst = sszutils.MarshalBool(dst, t.BlobDataAvailable) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestationData from SSZ-encoded bytes. +func (t *PayloadAttestationData) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 42 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 42) + } + if buflen > 42 { + return sszutils.ErrTrailingDataFn(buflen - 42) + } + { // Field #0 'BeaconBlockRoot' (static) + buf := buf[0:32] + copy(t.BeaconBlockRoot[:], buf) + } + { // Field #1 'Slot' (static) + buf := buf[32:40] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'PayloadPresent' (static) + buf := buf[40:41] + if buf[0] != 1 && buf[0] != 0 { + return sszutils.ErrInvalidBoolValueFn() + } + t.PayloadPresent = buf[0] == 1 + } + { // Field #3 'BlobDataAvailable' (static) + buf := buf[41:42] + if buf[0] != 1 && buf[0] != 0 { + return sszutils.ErrInvalidBoolValueFn() + } + t.BlobDataAvailable = buf[0] == 1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestationData. +func (t *PayloadAttestationData) SizeSSZ() (size int) { + return 42 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestationData. +func (t *PayloadAttestationData) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestationData using the given hash walker. +func (t *PayloadAttestationData) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestationData) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'BeaconBlockRoot' + hh.PutBytes(t.BeaconBlockRoot[:32]) + } + { // Field #1 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #2 'PayloadPresent' + hh.PutBool(t.PayloadPresent) + } + { // Field #3 'BlobDataAvailable' + hh.PutBool(t.BlobDataAvailable) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/payloadattestationdata_yaml.go b/spec/gloas/payloadattestationdata_yaml.go new file mode 100644 index 000000000..43d364e54 --- /dev/null +++ b/spec/gloas/payloadattestationdata_yaml.go @@ -0,0 +1,52 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestationData) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationDataJSON{ + BeaconBlockRoot: fmt.Sprintf("%#x", p.BeaconBlockRoot), + Slot: fmt.Sprintf("%d", p.Slot), + PayloadPresent: p.PayloadPresent, + BlobDataAvailable: p.BlobDataAvailable, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestationData) UnmarshalYAML(input []byte) error { + var data payloadAttestationDataJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/payloadattestationmessage.go b/spec/gloas/payloadattestationmessage.go new file mode 100644 index 000000000..8435774c2 --- /dev/null +++ b/spec/gloas/payloadattestationmessage.go @@ -0,0 +1,38 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestationMessage represents a payload attestation message. +type PayloadAttestationMessage struct { + ValidatorIndex phase0.ValidatorIndex + Data *PayloadAttestationData + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (p *PayloadAttestationMessage) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestationmessage_json.go b/spec/gloas/payloadattestationmessage_json.go new file mode 100644 index 000000000..f6d24e299 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_json.go @@ -0,0 +1,74 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// payloadAttestationMessageJSON is the spec representation of the struct. +type payloadAttestationMessageJSON struct { + ValidatorIndex string `json:"validator_index"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestationMessage) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationMessageJSON{ + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestationMessage) UnmarshalJSON(input []byte) error { + var data payloadAttestationMessageJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.ValidatorIndex == "" { + return errors.New("validator index missing") + } + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + if data.Data == nil { + return errors.New("data missing") + } + p.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(p.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/payloadattestationmessage_ssz.go b/spec/gloas/payloadattestationmessage_ssz.go new file mode 100644 index 000000000..80977b466 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 3908ebeeeffed33153f7c3f68780c43415eba9226b0a17b9aa73ddbe3e0a9892 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestationMessage](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestationMessage to SSZ-encoded bytes. +func (t *PayloadAttestationMessage) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestationMessage to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestationMessage) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestationMessage) + } + { // Static Field #0 'ValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ValidatorIndex)) + } + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestationMessage from SSZ-encoded bytes. +func (t *PayloadAttestationMessage) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 146 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 146) + } + if buflen > 146 { + return sszutils.ErrTrailingDataFn(buflen - 146) + } + { // Field #0 'ValidatorIndex' (static) + buf := buf[0:8] + t.ValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'Data' (static) + buf := buf[8:50] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[50:146] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestationMessage. +func (t *PayloadAttestationMessage) SizeSSZ() (size int) { + return 146 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestationMessage. +func (t *PayloadAttestationMessage) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestationMessage using the given hash walker. +func (t *PayloadAttestationMessage) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestationMessage) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ValidatorIndex' + hh.PutUint64(uint64(t.ValidatorIndex)) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/payloadattestationmessage_yaml.go b/spec/gloas/payloadattestationmessage_yaml.go new file mode 100644 index 000000000..2f452d2e2 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_yaml.go @@ -0,0 +1,51 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestationMessage) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationMessageJSON{ + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestationMessage) UnmarshalYAML(input []byte) error { + var data payloadAttestationMessageJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/proposerpreferences.go b/spec/gloas/proposerpreferences.go new file mode 100644 index 000000000..172833856 --- /dev/null +++ b/spec/gloas/proposerpreferences.go @@ -0,0 +1,41 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ProposerPreferences represents a proposer's preferences for execution payload construction. +type ProposerPreferences struct { + DependentRoot phase0.Root `ssz-size:"32"` + ProposalSlot phase0.Slot + ValidatorIndex phase0.ValidatorIndex + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + TargetGasLimit uint64 +} + +// String returns a string version of the structure. +func (p *ProposerPreferences) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/proposerpreferences_json.go b/spec/gloas/proposerpreferences_json.go new file mode 100644 index 000000000..07f961f36 --- /dev/null +++ b/spec/gloas/proposerpreferences_json.go @@ -0,0 +1,108 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// proposerPreferencesJSON is the spec representation of the struct. +type proposerPreferencesJSON struct { + DependentRoot string `json:"dependent_root"` + ProposalSlot string `json:"proposal_slot"` + ValidatorIndex string `json:"validator_index"` + FeeRecipient string `json:"fee_recipient"` + TargetGasLimit string `json:"target_gas_limit"` +} + +// MarshalJSON implements json.Marshaler. +func (p *ProposerPreferences) MarshalJSON() ([]byte, error) { + return json.Marshal(&proposerPreferencesJSON{ + DependentRoot: fmt.Sprintf("%#x", p.DependentRoot), + ProposalSlot: fmt.Sprintf("%d", p.ProposalSlot), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + FeeRecipient: fmt.Sprintf("%#x", p.FeeRecipient), + TargetGasLimit: fmt.Sprintf("%d", p.TargetGasLimit), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *ProposerPreferences) UnmarshalJSON(input []byte) error { + var data proposerPreferencesJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // Dependent root. + if data.DependentRoot == "" { + return errors.New("dependent root missing") + } + dependentRoot, err := hex.DecodeString(strings.TrimPrefix(data.DependentRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid dependent root") + } + if len(dependentRoot) != phase0.RootLength { + return errors.New("incorrect length for dependent root") + } + copy(p.DependentRoot[:], dependentRoot) + + // Proposal slot. + if data.ProposalSlot == "" { + return errors.New("proposal slot missing") + } + proposalSlot, err := strconv.ParseUint(data.ProposalSlot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid proposal slot") + } + p.ProposalSlot = phase0.Slot(proposalSlot) + + // Validator index. + if data.ValidatorIndex == "" { + return errors.New("validator index missing") + } + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + // Fee recipient. + if data.FeeRecipient == "" { + return errors.New("fee recipient missing") + } + feeRecipient, err := hex.DecodeString(strings.TrimPrefix(data.FeeRecipient, "0x")) + if err != nil { + return errors.Wrap(err, "invalid fee recipient") + } + copy(p.FeeRecipient[:], feeRecipient) + + // Target gas limit. + if data.TargetGasLimit == "" { + return errors.New("target gas limit missing") + } + targetGasLimit, err := strconv.ParseUint(data.TargetGasLimit, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid target gas limit") + } + p.TargetGasLimit = targetGasLimit + + return nil +} diff --git a/spec/gloas/proposerpreferences_ssz.go b/spec/gloas/proposerpreferences_ssz.go new file mode 100644 index 000000000..bb18f7895 --- /dev/null +++ b/spec/gloas/proposerpreferences_ssz.go @@ -0,0 +1,120 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b008b6f3176e10b2ec64ed7943073b8be0db0d370ff48dbf3a42ba68b9a889d9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ProposerPreferences](`ssz-static:"true"`) + +// MarshalSSZ marshals the *ProposerPreferences to SSZ-encoded bytes. +func (t *ProposerPreferences) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ProposerPreferences to SSZ-encoded bytes, appending to the provided buffer. +func (t *ProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ProposerPreferences) + } + { // Static Field #0 'DependentRoot' + dst = append(dst, t.DependentRoot[:32]...) + } + { // Static Field #1 'ProposalSlot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposalSlot)) + } + { // Static Field #2 'ValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ValidatorIndex)) + } + { // Static Field #3 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #4 'TargetGasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.TargetGasLimit) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ProposerPreferences from SSZ-encoded bytes. +func (t *ProposerPreferences) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 76 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 76) + } + if buflen > 76 { + return sszutils.ErrTrailingDataFn(buflen - 76) + } + { // Field #0 'DependentRoot' (static) + buf := buf[0:32] + copy(t.DependentRoot[:], buf) + } + { // Field #1 'ProposalSlot' (static) + buf := buf[32:40] + t.ProposalSlot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'ValidatorIndex' (static) + buf := buf[40:48] + t.ValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'FeeRecipient' (static) + buf := buf[48:68] + copy(t.FeeRecipient[:], buf) + } + { // Field #4 'TargetGasLimit' (static) + buf := buf[68:76] + t.TargetGasLimit = binary.LittleEndian.Uint64(buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ProposerPreferences. +func (t *ProposerPreferences) SizeSSZ() (size int) { + return 76 +} + +// HashTreeRoot computes the SSZ hash tree root of the *ProposerPreferences. +func (t *ProposerPreferences) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ProposerPreferences using the given hash walker. +func (t *ProposerPreferences) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ProposerPreferences) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'DependentRoot' + hh.PutBytes(t.DependentRoot[:32]) + } + { // Field #1 'ProposalSlot' + hh.PutUint64(uint64(t.ProposalSlot)) + } + { // Field #2 'ValidatorIndex' + hh.PutUint64(uint64(t.ValidatorIndex)) + } + { // Field #3 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #4 'TargetGasLimit' + hh.PutUint64(t.TargetGasLimit) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/proposerpreferences_yaml.go b/spec/gloas/proposerpreferences_yaml.go new file mode 100644 index 000000000..5e057e46f --- /dev/null +++ b/spec/gloas/proposerpreferences_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *ProposerPreferences) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&proposerPreferencesJSON{ + DependentRoot: fmt.Sprintf("%#x", p.DependentRoot), + ProposalSlot: fmt.Sprintf("%d", p.ProposalSlot), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + FeeRecipient: fmt.Sprintf("%#x", p.FeeRecipient), + TargetGasLimit: fmt.Sprintf("%d", p.TargetGasLimit), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *ProposerPreferences) UnmarshalYAML(input []byte) error { + var data proposerPreferencesJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedaggregateandproof.go b/spec/gloas/signedaggregateandproof.go new file mode 100644 index 000000000..6d615ae69 --- /dev/null +++ b/spec/gloas/signedaggregateandproof.go @@ -0,0 +1,120 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// SignedAggregateAndProof provides information about a signed aggregate and proof. +type SignedAggregateAndProof struct { + Message *AggregateAndProof + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// signedAggregateAndProofJSON is a raw representation of the struct. +type signedAggregateAndProofJSON struct { + Message *AggregateAndProof `json:"message"` + Signature string `json:"signature"` +} + +// signedAggregateAndProofYAML is a raw representation of the struct. +type signedAggregateAndProofYAML struct { + Message *AggregateAndProof `yaml:"message"` + Signature string `yaml:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedAggregateAndProof) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedAggregateAndProofJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedAggregateAndProof) UnmarshalJSON(input []byte) error { + var signedAggregateAndProofJSON signedAggregateAndProofJSON + if err := json.Unmarshal(input, &signedAggregateAndProofJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return s.unpack(&signedAggregateAndProofJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedAggregateAndProof) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedAggregateAndProofYAML{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedAggregateAndProof) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var signedAggregateAndProofJSON signedAggregateAndProofJSON + if err := yaml.Unmarshal(input, &signedAggregateAndProofJSON); err != nil { + return err + } + + return s.unpack(&signedAggregateAndProofJSON) +} + +// String returns a string version of the structure. +func (s *SignedAggregateAndProof) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (s *SignedAggregateAndProof) unpack(signedAggregateAndProofJSON *signedAggregateAndProofJSON) error { + if signedAggregateAndProofJSON.Message == nil { + return errors.New("message missing") + } + + s.Message = signedAggregateAndProofJSON.Message + if signedAggregateAndProofJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(signedAggregateAndProofJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedaggregateandproof_ssz.go b/spec/gloas/signedaggregateandproof_ssz.go new file mode 100644 index 000000000..fb6906524 --- /dev/null +++ b/spec/gloas/signedaggregateandproof_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 650130ca32152912881bfce4fe3cffa22cfb8d2970293fecedcd10be365c5c44 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedAggregateAndProof](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedAggregateAndProof to SSZ-encoded bytes. +func (t *SignedAggregateAndProof) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedAggregateAndProof to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedAggregateAndProof) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedAggregateAndProof) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(AggregateAndProof) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedAggregateAndProof from SSZ-encoded bytes. +func (t *SignedAggregateAndProof) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(AggregateAndProof) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedAggregateAndProof. +func (t *SignedAggregateAndProof) SizeSSZ() (size int) { + if t == nil { + t = new(SignedAggregateAndProof) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedAggregateAndProof. +func (t *SignedAggregateAndProof) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedAggregateAndProof using the given hash walker. +func (t *SignedAggregateAndProof) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedAggregateAndProof) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(AggregateAndProof) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedbeaconblock.go b/spec/gloas/signedbeaconblock.go new file mode 100644 index 000000000..3bb40583c --- /dev/null +++ b/spec/gloas/signedbeaconblock.go @@ -0,0 +1,37 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedBeaconBlock is a signed beacon block. +type SignedBeaconBlock struct { + Message *BeaconBlock + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedBeaconBlock) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedbeaconblock_json.go b/spec/gloas/signedbeaconblock_json.go new file mode 100644 index 000000000..dd4ce7d0f --- /dev/null +++ b/spec/gloas/signedbeaconblock_json.go @@ -0,0 +1,64 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +type signedBeaconBlockJSON struct { + Message *BeaconBlock `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedBeaconBlock) MarshalJSON() ([]byte, error) { + if s == nil { + return nil, nil + } + + return json.Marshal(&signedBeaconBlockJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedBeaconBlock) UnmarshalJSON(input []byte) error { + var data signedBeaconBlockJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedbeaconblock_ssz.go b/spec/gloas/signedbeaconblock_ssz.go new file mode 100644 index 000000000..d5640b1fd --- /dev/null +++ b/spec/gloas/signedbeaconblock_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 3bbb944c1b2c8d58c376c6e6dcf5bfc04b945efe9a44443f3d606df9d7aa9cb5 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedBeaconBlock](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedBeaconBlock to SSZ-encoded bytes. +func (t *SignedBeaconBlock) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedBeaconBlock to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedBeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedBeaconBlock) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(BeaconBlock) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedBeaconBlock from SSZ-encoded bytes. +func (t *SignedBeaconBlock) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(BeaconBlock) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedBeaconBlock. +func (t *SignedBeaconBlock) SizeSSZ() (size int) { + if t == nil { + t = new(SignedBeaconBlock) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedBeaconBlock. +func (t *SignedBeaconBlock) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedBeaconBlock using the given hash walker. +func (t *SignedBeaconBlock) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedBeaconBlock) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(BeaconBlock) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedbeaconblock_yaml.go b/spec/gloas/signedbeaconblock_yaml.go new file mode 100644 index 000000000..a00b0c683 --- /dev/null +++ b/spec/gloas/signedbeaconblock_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedBeaconBlock) MarshalYAML() ([]byte, error) { + if s == nil { + return nil, nil + } + + data, err := json.Marshal(s) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal JSON") + } + + yamlBytes, err := yaml.JSONToYAML(data) + if err != nil { + return nil, errors.Wrap(err, "failed to convert JSON to YAML") + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedBeaconBlock) UnmarshalYAML(input []byte) error { + var data signedBeaconBlockJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedexecutionpayloadbid.go b/spec/gloas/signedexecutionpayloadbid.go new file mode 100644 index 000000000..0c5d25252 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid.go @@ -0,0 +1,37 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadBid represents a signed execution payload bid. +type SignedExecutionPayloadBid struct { + Message *ExecutionPayloadBid + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadBid) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedexecutionpayloadbid_json.go b/spec/gloas/signedexecutionpayloadbid_json.go new file mode 100644 index 000000000..4e0a26748 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_json.go @@ -0,0 +1,61 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// signedExecutionPayloadBidJSON is the spec representation of the struct. +type signedExecutionPayloadBidJSON struct { + Message *ExecutionPayloadBid `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedExecutionPayloadBid) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedExecutionPayloadBidJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadBid) UnmarshalJSON(input []byte) error { + var data signedExecutionPayloadBidJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedexecutionpayloadbid_ssz.go b/spec/gloas/signedexecutionpayloadbid_ssz.go new file mode 100644 index 000000000..cc995534c --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 9f0e256740a86de8e5f70563414d7a63dd090cf46ea2b0b94f5df4b7d197e530 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadBid](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadBid to SSZ-encoded bytes. +func (t *SignedExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadBid to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedExecutionPayloadBid) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(ExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadBid from SSZ-encoded bytes. +func (t *SignedExecutionPayloadBid) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(ExecutionPayloadBid) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadBid. +func (t *SignedExecutionPayloadBid) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadBid) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadBid. +func (t *SignedExecutionPayloadBid) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadBid using the given hash walker. +func (t *SignedExecutionPayloadBid) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadBid) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedexecutionpayloadbid_yaml.go b/spec/gloas/signedexecutionpayloadbid_yaml.go new file mode 100644 index 000000000..d5adfadaa --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedExecutionPayloadBid) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadBidJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadBid) UnmarshalYAML(input []byte) error { + var data signedExecutionPayloadBidJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedexecutionpayloadenvelope.go b/spec/gloas/signedexecutionpayloadenvelope.go new file mode 100644 index 000000000..d0634900a --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope.go @@ -0,0 +1,37 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadEnvelope represents a signed execution payload envelope. +type SignedExecutionPayloadEnvelope struct { + Message *ExecutionPayloadEnvelope + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadEnvelope) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_json.go b/spec/gloas/signedexecutionpayloadenvelope_json.go new file mode 100644 index 000000000..9eec83eb2 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_json.go @@ -0,0 +1,61 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// signedExecutionPayloadEnvelopeJSON is the spec representation of the struct. +type signedExecutionPayloadEnvelopeJSON struct { + Message *ExecutionPayloadEnvelope `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedExecutionPayloadEnvelope) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedExecutionPayloadEnvelopeJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { + var data signedExecutionPayloadEnvelopeJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_ssz.go b/spec/gloas/signedexecutionpayloadenvelope_ssz.go new file mode 100644 index 000000000..3959a0ba4 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 0e8052523366a012e897e4234d9226f6d5981e66a1961f9c4822cb103dc154f7 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadEnvelope](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadEnvelope to SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadEnvelope to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadEnvelope from SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(ExecutionPayloadEnvelope) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadEnvelope. +func (t *SignedExecutionPayloadEnvelope) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelope. +func (t *SignedExecutionPayloadEnvelope) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelope using the given hash walker. +func (t *SignedExecutionPayloadEnvelope) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_yaml.go b/spec/gloas/signedexecutionpayloadenvelope_yaml.go new file mode 100644 index 000000000..7839dc556 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedExecutionPayloadEnvelope) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadEnvelopeJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadEnvelope) UnmarshalYAML(input []byte) error { + var data signedExecutionPayloadEnvelopeJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedproposerpreferences.go b/spec/gloas/signedproposerpreferences.go new file mode 100644 index 000000000..25f18a66c --- /dev/null +++ b/spec/gloas/signedproposerpreferences.go @@ -0,0 +1,37 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedProposerPreferences represents a signed proposer preferences message. +type SignedProposerPreferences struct { + Message *ProposerPreferences + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedProposerPreferences) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedproposerpreferences_json.go b/spec/gloas/signedproposerpreferences_json.go new file mode 100644 index 000000000..fdc193ff7 --- /dev/null +++ b/spec/gloas/signedproposerpreferences_json.go @@ -0,0 +1,61 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// signedProposerPreferencesJSON is the spec representation of the struct. +type signedProposerPreferencesJSON struct { + Message *ProposerPreferences `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedProposerPreferences) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedProposerPreferencesJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedProposerPreferences) UnmarshalJSON(input []byte) error { + var data signedProposerPreferencesJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedproposerpreferences_ssz.go b/spec/gloas/signedproposerpreferences_ssz.go new file mode 100644 index 000000000..36444558e --- /dev/null +++ b/spec/gloas/signedproposerpreferences_ssz.go @@ -0,0 +1,104 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: ab6225206ea43f8e7c2b2020c769383723d0205c6b8babc792d4f71096b7f2b3 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedProposerPreferences](`ssz-static:"true"`) + +// MarshalSSZ marshals the *SignedProposerPreferences to SSZ-encoded bytes. +func (t *SignedProposerPreferences) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedProposerPreferences to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedProposerPreferences) + } + { // Static Field #0 'Message' + t := t.Message + if t == nil { + t = new(ProposerPreferences) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedProposerPreferences from SSZ-encoded bytes. +func (t *SignedProposerPreferences) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 172 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 172) + } + if buflen > 172 { + return sszutils.ErrTrailingDataFn(buflen - 172) + } + { // Field #0 'Message' (static) + buf := buf[0:76] + if t.Message == nil { + t.Message = new(ProposerPreferences) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' (static) + buf := buf[76:172] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedProposerPreferences. +func (t *SignedProposerPreferences) SizeSSZ() (size int) { + return 172 +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedProposerPreferences. +func (t *SignedProposerPreferences) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedProposerPreferences using the given hash walker. +func (t *SignedProposerPreferences) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedProposerPreferences) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ProposerPreferences) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedproposerpreferences_yaml.go b/spec/gloas/signedproposerpreferences_yaml.go new file mode 100644 index 000000000..3f1c8ea3a --- /dev/null +++ b/spec/gloas/signedproposerpreferences_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedProposerPreferences) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedProposerPreferencesJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedProposerPreferences) UnmarshalYAML(input []byte) error { + var data signedProposerPreferencesJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/version.go b/spec/version.go new file mode 100644 index 000000000..d54427cd8 --- /dev/null +++ b/spec/version.go @@ -0,0 +1,47 @@ +// Copyright © 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ( + "github.com/attestantio/go-eth2-client/spec/version" +) + +// The version types and constants live in the leaf spec/version package so that +// fork packages can depend on them without importing spec (which would create an +// import cycle). These aliases keep the historical spec.DataVersion* surface +// intact — additive, non-breaking per ADR-0001. +type ( + BuilderVersion = version.BuilderVersion + DataVersion = version.DataVersion +) + +// Constant aliases preserve the original const semantics of these identifiers. +const ( + DataVersionUnknown = version.DataVersionUnknown + DataVersionPhase0 = version.DataVersionPhase0 + DataVersionAltair = version.DataVersionAltair + DataVersionBellatrix = version.DataVersionBellatrix + DataVersionCapella = version.DataVersionCapella + DataVersionDeneb = version.DataVersionDeneb + DataVersionElectra = version.DataVersionElectra + DataVersionFulu = version.DataVersionFulu + DataVersionGloas = version.DataVersionGloas + + BuilderVersionV1 = version.BuilderVersionV1 +) + +// DataVersionFromString turns a fork string into a DataVersion, returning an +// error if the fork is not recognised. Preserved here (the fork drops it from +// package spec) to keep the port additive per ADR-0001. +var DataVersionFromString = version.DataVersionFromString diff --git a/spec/builderversion.go b/spec/version/builderversion.go similarity index 99% rename from spec/builderversion.go rename to spec/version/builderversion.go index fcd6036eb..4a7917d36 100644 --- a/spec/builderversion.go +++ b/spec/version/builderversion.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package spec +package version import ( "fmt" diff --git a/spec/dataversion.go b/spec/version/dataversion.go similarity index 94% rename from spec/dataversion.go rename to spec/version/dataversion.go index 608cd3541..2a1e91b8b 100644 --- a/spec/dataversion.go +++ b/spec/version/dataversion.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package spec +package version import ( "fmt" @@ -38,6 +38,8 @@ const ( DataVersionElectra // DataVersionFulu is data applicable for the Fulu release of the beacon chain. DataVersionFulu + // DataVersionGloas is data applicable for the Gloas release of the beacon chain. + DataVersionGloas ) var dataVersionStrings = [...]string{ @@ -49,6 +51,7 @@ var dataVersionStrings = [...]string{ "deneb", "electra", "fulu", + "gloas", } var dataVersionMap = map[string]DataVersion{ @@ -59,6 +62,7 @@ var dataVersionMap = map[string]DataVersion{ `"deneb"`: DataVersionDeneb, `"electra"`: DataVersionElectra, `"fulu"`: DataVersionFulu, + `"gloas"`: DataVersionGloas, } // MarshalJSON implements json.Marshaler. diff --git a/spec/versionedaggregateandproof.go b/spec/versionedaggregateandproof.go index 9e6449bb2..70e478f56 100644 --- a/spec/versionedaggregateandproof.go +++ b/spec/versionedaggregateandproof.go @@ -30,6 +30,7 @@ type VersionedAggregateAndProof struct { Deneb *phase0.AggregateAndProof Electra *electra.AggregateAndProof Fulu *electra.AggregateAndProof + Gloas *electra.AggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -77,6 +78,12 @@ func (v *VersionedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIndex, e } return v.Fulu.AggregatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.AggregatorIndex, nil default: return 0, errors.New("unknown version for aggregate and proof") } @@ -127,6 +134,12 @@ func (v *VersionedAggregateAndProof) HashTreeRoot() ([32]byte, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.HashTreeRoot() default: return [32]byte{}, errors.New("unknown version") } @@ -135,7 +148,7 @@ func (v *VersionedAggregateAndProof) HashTreeRoot() ([32]byte, error) { // IsEmpty returns true if there is no aggregate and proof. func (v *VersionedAggregateAndProof) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // String returns a string version of the structure. @@ -183,6 +196,12 @@ func (v *VersionedAggregateAndProof) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } @@ -233,6 +252,12 @@ func (v *VersionedAggregateAndProof) SelectionProof() (phase0.BLSSignature, erro } return v.Fulu.SelectionProof, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.SelectionProof, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } diff --git a/spec/versionedattestation.go b/spec/versionedattestation.go index 9a143245d..48a5471e1 100644 --- a/spec/versionedattestation.go +++ b/spec/versionedattestation.go @@ -19,6 +19,7 @@ import ( "github.com/OffchainLabs/go-bitfield" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -33,12 +34,13 @@ type VersionedAttestation struct { Deneb *phase0.Attestation Electra *electra.Attestation Fulu *electra.Attestation + Gloas *gloas.Attestation } // IsEmpty returns true if there is no block. func (v *VersionedAttestation) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // AggregationBits returns the aggregation bits of the attestation. @@ -86,6 +88,12 @@ func (v *VersionedAttestation) AggregationBits() (bitfield.Bitlist, error) { } return v.Fulu.AggregationBits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.AggregationBits, nil default: return nil, errors.New("unknown version") } @@ -136,6 +144,12 @@ func (v *VersionedAttestation) Data() (*phase0.AttestationData, error) { } return v.Fulu.Data, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.Data, nil default: return nil, fmt.Errorf("unknown version: %d", v.Version) } @@ -158,6 +172,12 @@ func (v *VersionedAttestation) CommitteeBits() (bitfield.Bitvector64, error) { } return v.Fulu.CommitteeBits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.CommitteeBits, nil default: return nil, errors.New("unknown version") } @@ -208,6 +228,12 @@ func (v *VersionedAttestation) CommitteeIndex() (phase0.CommitteeIndex, error) { } return v.Fulu.CommitteeIndex() + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas attestation") + } + + return v.Gloas.CommitteeIndex() default: return 0, errors.New("unknown version") } @@ -257,6 +283,12 @@ func (v *VersionedAttestation) HashTreeRoot() ([32]byte, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no Gloas attestation") + } + + return v.Gloas.HashTreeRoot() default: return [32]byte{}, errors.New("unknown version") } @@ -307,6 +339,12 @@ func (v *VersionedAttestation) Signature() (phase0.BLSSignature, error) { } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no Gloas attestation") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -357,6 +395,12 @@ func (v *VersionedAttestation) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedattesterslashing.go b/spec/versionedattesterslashing.go index 8bf880f85..c76981301 100644 --- a/spec/versionedattesterslashing.go +++ b/spec/versionedattesterslashing.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,12 +31,13 @@ type VersionedAttesterSlashing struct { Deneb *phase0.AttesterSlashing Electra *electra.AttesterSlashing Fulu *electra.AttesterSlashing + Gloas *gloas.AttesterSlashing } // IsEmpty returns true if there is no block. func (v *VersionedAttesterSlashing) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Attestation1 returns the first indexed attestation. @@ -117,6 +119,17 @@ func (v *VersionedAttesterSlashing) Attestation1() (*VersionedIndexedAttestation Fulu: v.Fulu.Attestation1, } + return &versionedIndexedAttestation, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + versionedIndexedAttestation := VersionedIndexedAttestation{ + Version: DataVersionGloas, + Gloas: v.Gloas.Attestation1, + } + return &versionedIndexedAttestation, nil default: return nil, errors.New("unknown version") @@ -202,6 +215,17 @@ func (v *VersionedAttesterSlashing) Attestation2() (*VersionedIndexedAttestation Fulu: v.Fulu.Attestation2, } + return &versionedIndexedAttestation, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + versionedIndexedAttestation := VersionedIndexedAttestation{ + Version: DataVersionGloas, + Gloas: v.Gloas.Attestation2, + } + return &versionedIndexedAttestation, nil default: return nil, errors.New("unknown version") @@ -253,6 +277,12 @@ func (v *VersionedAttesterSlashing) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconblock.go b/spec/versionedbeaconblock.go index 9daa71493..f55a5c774 100644 --- a/spec/versionedbeaconblock.go +++ b/spec/versionedbeaconblock.go @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -34,12 +35,13 @@ type VersionedBeaconBlock struct { Deneb *deneb.BeaconBlock Electra *electra.BeaconBlock Fulu *electra.BeaconBlock + Gloas *gloas.BeaconBlock } // IsEmpty returns true if there is no block. func (v *VersionedBeaconBlock) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && v.Capella == nil && - v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Slot returns the slot of the beacon block. @@ -87,6 +89,12 @@ func (v *VersionedBeaconBlock) Slot() (phase0.Slot, error) { } return v.Fulu.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Slot, nil default: return 0, errors.New("unknown version") } @@ -165,6 +173,15 @@ func (v *VersionedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) { } return v.Fulu.Body.RANDAOReveal, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return phase0.BLSSignature{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.RANDAOReveal, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -243,6 +260,15 @@ func (v *VersionedBeaconBlock) Graffiti() ([32]byte, error) { } return v.Fulu.Body.Graffiti, nil + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return [32]byte{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.Graffiti, nil default: return [32]byte{}, errors.New("unknown version") } @@ -293,6 +319,12 @@ func (v *VersionedBeaconBlock) ProposerIndex() (phase0.ValidatorIndex, error) { } return v.Fulu.ProposerIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.ProposerIndex, nil default: return 0, errors.New("unknown version") } @@ -343,6 +375,12 @@ func (v *VersionedBeaconBlock) Root() (phase0.Root, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -421,6 +459,15 @@ func (v *VersionedBeaconBlock) BodyRoot() (phase0.Root, error) { } return v.Fulu.Body.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return phase0.Root{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -471,6 +518,12 @@ func (v *VersionedBeaconBlock) ParentRoot() (phase0.Root, error) { } return v.Fulu.ParentRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.ParentRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -521,12 +574,20 @@ func (v *VersionedBeaconBlock) StateRoot() (phase0.Root, error) { } return v.Fulu.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } } // Attestations returns the attestations of the beacon block. +// +//nolint:gocyclo func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { switch v.Version { case DataVersionPhase0: @@ -626,6 +687,20 @@ func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { } } + return versionedAttestations, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttestations := make([]VersionedAttestation, len(v.Gloas.Body.Attestations)) + for i, attestation := range v.Gloas.Body.Attestations { + versionedAttestations[i] = VersionedAttestation{ + Version: DataVersionGloas, + Gloas: attestation, + } + } + return versionedAttestations, nil default: return nil, errors.New("unknown version") @@ -633,6 +708,8 @@ func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { } // AttesterSlashings returns the attester slashings of the beacon block. +// +//nolint:gocyclo func (v *VersionedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSlashing, error) { switch v.Version { case DataVersionPhase0: @@ -732,6 +809,20 @@ func (v *VersionedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSlashing, } } + return versionedAttesterSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttesterSlashings := make([]VersionedAttesterSlashing, len(v.Gloas.Body.AttesterSlashings)) + for i, attesterSlashing := range v.Gloas.Body.AttesterSlashings { + versionedAttesterSlashings[i] = VersionedAttesterSlashing{ + Version: DataVersionGloas, + Gloas: attesterSlashing, + } + } + return versionedAttesterSlashings, nil default: return nil, errors.New("unknown version") @@ -783,6 +874,43 @@ func (v *VersionedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlashing, } return v.Fulu.Body.ProposerSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Body.ProposerSlashings, nil + default: + return nil, errors.New("unknown version") + } +} + +// SignedExecutionPayloadBid returns the execution payload bid of the beacon block. +func (v *VersionedBeaconBlock) SignedExecutionPayloadBid() (*VersionedSignedExecutionPayloadBid, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no signed execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no signed execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no signed execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no signed execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no signed execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no signed execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no signed execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + return &VersionedSignedExecutionPayloadBid{ + Version: DataVersionGloas, + Gloas: v.Gloas.Body.SignedExecutionPayloadBid, + }, nil default: return nil, errors.New("unknown version") } @@ -829,6 +957,8 @@ func (v *VersionedBeaconBlock) ExecutionPayload() (*VersionedExecutionPayload, e } versionedExecutionPayload.Fulu = v.Fulu.Body.ExecutionPayload + case DataVersionGloas: + return nil, errors.New("no execution payload in gloas") default: return nil, errors.New("unknown version") } @@ -881,6 +1011,12 @@ func (v *VersionedBeaconBlock) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconblockbody.go b/spec/versionedbeaconblockbody.go index 40af4ab6a..d212bc652 100644 --- a/spec/versionedbeaconblockbody.go +++ b/spec/versionedbeaconblockbody.go @@ -19,6 +19,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -32,6 +33,7 @@ type VersionedBeaconBlockBody struct { Deneb *deneb.BeaconBlockBody Electra *electra.BeaconBlockBody Fulu *electra.BeaconBlockBody + Gloas *gloas.BeaconBlockBody } // String returns a string version of the structure. @@ -79,6 +81,12 @@ func (v *VersionedBeaconBlockBody) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconstate.go b/spec/versionedbeaconstate.go index 8b5398a07..aeee50e1b 100644 --- a/spec/versionedbeaconstate.go +++ b/spec/versionedbeaconstate.go @@ -22,6 +22,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/fulu" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" proofutil "github.com/attestantio/go-eth2-client/util/proof" dynssz "github.com/pk910/dynamic-ssz" @@ -38,12 +39,13 @@ type VersionedBeaconState struct { Deneb *deneb.BeaconState Electra *electra.BeaconState Fulu *fulu.BeaconState + Gloas *gloas.BeaconState } // IsEmpty returns true if there is no block. func (v *VersionedBeaconState) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Slot returns the slot of the state. @@ -91,6 +93,12 @@ func (v *VersionedBeaconState) Slot() (phase0.Slot, error) { } return v.Fulu.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.Slot, nil default: return 0, errors.New("unknown version") } @@ -125,6 +133,12 @@ func (v *VersionedBeaconState) NextWithdrawalValidatorIndex() (phase0.ValidatorI } return v.Fulu.NextWithdrawalValidatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.NextWithdrawalValidatorIndex, nil default: return 0, errors.New("unknown version") } @@ -175,6 +189,12 @@ func (v *VersionedBeaconState) Validators() ([]*phase0.Validator, error) { } return v.Fulu.Validators, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Validators, nil default: return nil, errors.New("unknown version") } @@ -225,6 +245,12 @@ func (v *VersionedBeaconState) ValidatorBalances() ([]phase0.Gwei, error) { } return v.Fulu.Balances, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Balances, nil default: return nil, errors.New("unknown version") } @@ -247,6 +273,12 @@ func (v *VersionedBeaconState) DepositRequestsStartIndex() (uint64, error) { } return v.Fulu.DepositRequestsStartIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.DepositRequestsStartIndex, nil default: return 0, errors.New("unknown version") } @@ -269,6 +301,12 @@ func (v *VersionedBeaconState) DepositBalanceToConsume() (phase0.Gwei, error) { } return v.Fulu.DepositBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.DepositBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -291,6 +329,12 @@ func (v *VersionedBeaconState) ExitBalanceToConsume() (phase0.Gwei, error) { } return v.Fulu.ExitBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.ExitBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -313,6 +357,12 @@ func (v *VersionedBeaconState) EarliestExitEpoch() (phase0.Epoch, error) { } return v.Fulu.EarliestExitEpoch, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.EarliestExitEpoch, nil default: return 0, errors.New("unknown version") } @@ -335,6 +385,12 @@ func (v *VersionedBeaconState) ConsolidationBalanceToConsume() (phase0.Gwei, err } return v.Fulu.ConsolidationBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.ConsolidationBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -357,6 +413,12 @@ func (v *VersionedBeaconState) EarliestConsolidationEpoch() (phase0.Epoch, error } return v.Fulu.EarliestConsolidationEpoch, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.EarliestConsolidationEpoch, nil default: return 0, errors.New("unknown version") } @@ -379,6 +441,12 @@ func (v *VersionedBeaconState) PendingDeposits() ([]*electra.PendingDeposit, err } return v.Fulu.PendingDeposits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingDeposits, nil default: return nil, errors.New("unknown version") } @@ -401,6 +469,12 @@ func (v *VersionedBeaconState) PendingPartialWithdrawals() ([]*electra.PendingPa } return v.Fulu.PendingPartialWithdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingPartialWithdrawals, nil default: return nil, errors.New("unknown version") } @@ -423,6 +497,46 @@ func (v *VersionedBeaconState) PendingConsolidations() ([]*electra.PendingConsol } return v.Fulu.PendingConsolidations, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingConsolidations, nil + default: + return nil, errors.New("unknown version") + } +} + +// Builders returns the builders of the state. +func (v *VersionedBeaconState) Builders() ([]*gloas.Builder, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, errors.New("state does not provide builders") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Builders, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionPayloadAvailability returns the execution payload availability of the state. +func (v *VersionedBeaconState) ExecutionPayloadAvailability() ([]uint8, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, errors.New("state does not provide execution payload availability") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.ExecutionPayloadAvailability, nil default: return nil, errors.New("unknown version") } @@ -515,6 +629,12 @@ func (v *VersionedBeaconState) GetTree() (*treeproof.Node, error) { } return dynssz.GetGlobalDynSsz().GetTree(v.Fulu) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return dynssz.GetGlobalDynSsz().GetTree(v.Gloas) default: return nil, errors.New("unknown version") } @@ -565,6 +685,12 @@ func (v *VersionedBeaconState) HashTreeRoot() (phase0.Hash32, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no Gloas state") + } + + return v.Gloas.HashTreeRoot() default: return phase0.Hash32{}, errors.New("unknown version") } @@ -617,6 +743,12 @@ func (v *VersionedBeaconState) FieldIndex(name string) (int, error) { } return proofutil.FieldIndex(v.Fulu, name) + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return proofutil.FieldIndex(v.Gloas, name) default: return 0, errors.New("unknown version") } @@ -670,6 +802,12 @@ func (v *VersionedBeaconState) FieldGeneralizedIndex(name string) (int, error) { } return proofutil.FieldGeneralizedIndex(v.Fulu, name) + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return proofutil.FieldGeneralizedIndex(v.Gloas, name) default: return 0, errors.New("unknown version") } @@ -829,6 +967,12 @@ func (v *VersionedBeaconState) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedexecutionpayload.go b/spec/versionedexecutionpayload.go index 3af836dd8..36804d90c 100644 --- a/spec/versionedexecutionpayload.go +++ b/spec/versionedexecutionpayload.go @@ -19,6 +19,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/holiman/uint256" ) @@ -31,12 +32,13 @@ type VersionedExecutionPayload struct { Deneb *deneb.ExecutionPayload Electra *deneb.ExecutionPayload Fulu *deneb.ExecutionPayload + Gloas *gloas.ExecutionPayload } // IsEmpty returns true if there is no block. func (v *VersionedExecutionPayload) IsEmpty() bool { return v.Version < DataVersionBellatrix || (v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil) + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil) } // ParentHash returns the parent hash of the execution payload. @@ -76,6 +78,12 @@ func (v *VersionedExecutionPayload) ParentHash() (phase0.Hash32, error) { } return v.Fulu.ParentHash, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload") + } + + return v.Gloas.ParentHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -118,6 +126,12 @@ func (v *VersionedExecutionPayload) FeeRecipient() (bellatrix.ExecutionAddress, } return v.Fulu.FeeRecipient, nil + case DataVersionGloas: + if v.Gloas == nil { + return bellatrix.ExecutionAddress{}, errors.New("no gloas execution payload") + } + + return v.Gloas.FeeRecipient, nil default: return bellatrix.ExecutionAddress{}, errors.New("unknown version") } @@ -160,6 +174,12 @@ func (v *VersionedExecutionPayload) StateRoot() (phase0.Root, error) { } return v.Fulu.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload") + } + + return v.Gloas.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -202,6 +222,12 @@ func (v *VersionedExecutionPayload) ReceiptsRoot() (phase0.Root, error) { } return v.Fulu.ReceiptsRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload") + } + + return v.Gloas.ReceiptsRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -244,6 +270,12 @@ func (v *VersionedExecutionPayload) LogsBloom() ([256]byte, error) { } return v.Fulu.LogsBloom, nil + case DataVersionGloas: + if v.Gloas == nil { + return [256]byte{}, errors.New("no gloas execution payload") + } + + return v.Gloas.LogsBloom, nil default: return [256]byte{}, errors.New("unknown version") } @@ -286,6 +318,12 @@ func (v *VersionedExecutionPayload) PrevRandao() ([32]byte, error) { } return v.Fulu.PrevRandao, nil + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas execution payload") + } + + return v.Gloas.PrevRandao, nil default: return [32]byte{}, errors.New("unknown version") } @@ -328,6 +366,12 @@ func (v *VersionedExecutionPayload) BlockNumber() (uint64, error) { } return v.Fulu.BlockNumber, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.BlockNumber, nil default: return 0, errors.New("unknown version") } @@ -370,6 +414,12 @@ func (v *VersionedExecutionPayload) GasLimit() (uint64, error) { } return v.Fulu.GasLimit, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.GasLimit, nil default: return 0, errors.New("unknown version") } @@ -412,6 +462,12 @@ func (v *VersionedExecutionPayload) GasUsed() (uint64, error) { } return v.Fulu.GasUsed, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.GasUsed, nil default: return 0, errors.New("unknown version") } @@ -454,6 +510,12 @@ func (v *VersionedExecutionPayload) Timestamp() (uint64, error) { } return v.Fulu.Timestamp, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.Timestamp, nil default: return 0, errors.New("unknown version") } @@ -496,6 +558,12 @@ func (v *VersionedExecutionPayload) ExtraData() ([]byte, error) { } return v.Fulu.ExtraData, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.ExtraData, nil default: return nil, errors.New("unknown version") } @@ -538,6 +606,12 @@ func (v *VersionedExecutionPayload) BaseFeePerGas() (*uint256.Int, error) { } return v.Fulu.BaseFeePerGas, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.BaseFeePerGas, nil default: return nil, errors.New("unknown version") } @@ -580,6 +654,12 @@ func (v *VersionedExecutionPayload) BlockHash() (phase0.Hash32, error) { } return v.Fulu.BlockHash, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload") + } + + return v.Gloas.BlockHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -622,6 +702,12 @@ func (v *VersionedExecutionPayload) Transactions() ([]bellatrix.Transaction, err } return v.Fulu.Transactions, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.Transactions, nil default: return nil, errors.New("unknown version") } @@ -660,6 +746,12 @@ func (v *VersionedExecutionPayload) Withdrawals() ([]*capella.Withdrawal, error) } return v.Fulu.Withdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.Withdrawals, nil default: return nil, errors.New("unknown version") } @@ -694,6 +786,12 @@ func (v *VersionedExecutionPayload) BlobGasUsed() (uint64, error) { } return v.Fulu.BlobGasUsed, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.BlobGasUsed, nil default: return 0, errors.New("unknown version") } @@ -728,6 +826,12 @@ func (v *VersionedExecutionPayload) ExcessBlobGas() (uint64, error) { } return v.Fulu.ExcessBlobGas, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.ExcessBlobGas, nil default: return 0, errors.New("unknown version") } @@ -770,6 +874,12 @@ func (v *VersionedExecutionPayload) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedexecutionpayloadenvelope.go b/spec/versionedexecutionpayloadenvelope.go new file mode 100644 index 000000000..bc8cdc9eb --- /dev/null +++ b/spec/versionedexecutionpayloadenvelope.go @@ -0,0 +1,194 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedExecutionPayloadEnvelope contains a versioned execution payload +// envelope (EIP-7732). The envelope is a gloas-onwards container; this +// wrapper exists so callers can treat it uniformly with other versioned types. +type VersionedExecutionPayloadEnvelope struct { + Version DataVersion + Gloas *gloas.ExecutionPayloadEnvelope +} + +// IsEmpty returns true if no fork-specific envelope is populated. +func (v *VersionedExecutionPayloadEnvelope) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedExecutionPayloadEnvelope) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Payload returns the execution payload of the envelope. +func (v *VersionedExecutionPayloadEnvelope) Payload() (*gloas.ExecutionPayload, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.Payload, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionRequests returns the execution requests of the envelope. +func (v *VersionedExecutionPayloadEnvelope) ExecutionRequests() (*VersionedExecutionRequests, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload envelope") + } + + return &VersionedExecutionRequests{ + Version: DataVersionGloas, + Gloas: v.Gloas.ExecutionRequests, + }, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the envelope. +func (v *VersionedExecutionPayloadEnvelope) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// BeaconBlockRoot returns the beacon block root of the envelope. +func (v *VersionedExecutionPayloadEnvelope) BeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.BeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// ParentBeaconBlockRoot returns the parent beacon block root of the envelope. +func (v *VersionedExecutionPayloadEnvelope) ParentBeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.ParentBeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} diff --git a/spec/versionedexecutionrequests.go b/spec/versionedexecutionrequests.go new file mode 100644 index 000000000..6b93f8228 --- /dev/null +++ b/spec/versionedexecutionrequests.go @@ -0,0 +1,182 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// VersionedExecutionRequests contains versioned execution layer triggered +// requests. The container is introduced in Electra; builder requests are added +// in Gloas (EIP-8282). +type VersionedExecutionRequests struct { + Version DataVersion + Electra *electra.ExecutionRequests + Fulu *electra.ExecutionRequests + Gloas *gloas.ExecutionRequests +} + +// IsEmpty returns true if no fork-specific execution requests are populated. +func (v *VersionedExecutionRequests) IsEmpty() bool { + return v.Electra == nil && v.Fulu == nil && v.Gloas == nil +} + +// Deposits returns the deposit requests. +func (v *VersionedExecutionRequests) Deposits() ([]*electra.DepositRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Deposits, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Deposits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Deposits, nil + default: + return nil, errors.New("unknown version") + } +} + +// Withdrawals returns the withdrawal requests. +func (v *VersionedExecutionRequests) Withdrawals() ([]*electra.WithdrawalRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Withdrawals, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Withdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Withdrawals, nil + default: + return nil, errors.New("unknown version") + } +} + +// Consolidations returns the consolidation requests. +func (v *VersionedExecutionRequests) Consolidations() ([]*electra.ConsolidationRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Consolidations, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Consolidations, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Consolidations, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderDeposits returns the builder deposit requests (EIP-8282, gloas onwards). +func (v *VersionedExecutionRequests) BuilderDeposits() ([]*gloas.BuilderDepositRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb, + DataVersionElectra, DataVersionFulu: + return nil, errors.New("builder deposit requests are not present before gloas") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.BuilderDeposits, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderExits returns the builder exit requests (EIP-8282, gloas onwards). +func (v *VersionedExecutionRequests) BuilderExits() ([]*gloas.BuilderExitRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb, + DataVersionElectra, DataVersionFulu: + return nil, errors.New("builder exit requests are not present before gloas") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.BuilderExits, nil + default: + return nil, errors.New("unknown version") + } +} + +// String returns a string version of the structure. +func (v *VersionedExecutionRequests) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return "" + case DataVersionElectra: + if v.Electra == nil { + return "" + } + + return v.Electra.String() + case DataVersionFulu: + if v.Fulu == nil { + return "" + } + + return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} diff --git a/spec/versionedindexedattestation.go b/spec/versionedindexedattestation.go index 1bce21964..49c779d6d 100644 --- a/spec/versionedindexedattestation.go +++ b/spec/versionedindexedattestation.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,12 +31,13 @@ type VersionedIndexedAttestation struct { Deneb *phase0.IndexedAttestation Electra *electra.IndexedAttestation Fulu *electra.IndexedAttestation + Gloas *gloas.IndexedAttestation } // IsEmpty returns true if there is no block. func (v *VersionedIndexedAttestation) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // AttestingIndices returns the attesting indices of the indexed attestation. @@ -83,6 +85,12 @@ func (v *VersionedIndexedAttestation) AttestingIndices() ([]uint64, error) { } return v.Fulu.AttestingIndices, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.AttestingIndices, nil default: return nil, errors.New("unknown version") } @@ -133,6 +141,12 @@ func (v *VersionedIndexedAttestation) Data() (*phase0.AttestationData, error) { } return v.Fulu.Data, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.Data, nil default: return nil, errors.New("unknown version") } @@ -183,6 +197,12 @@ func (v *VersionedIndexedAttestation) Signature() (phase0.BLSSignature, error) { } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -233,6 +253,12 @@ func (v *VersionedIndexedAttestation) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedsignedaggregateandproof.go b/spec/versionedsignedaggregateandproof.go index 508f9932c..7de312928 100644 --- a/spec/versionedsignedaggregateandproof.go +++ b/spec/versionedsignedaggregateandproof.go @@ -30,6 +30,7 @@ type VersionedSignedAggregateAndProof struct { Deneb *phase0.SignedAggregateAndProof Electra *electra.SignedAggregateAndProof Fulu *electra.SignedAggregateAndProof + Gloas *electra.SignedAggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -77,6 +78,12 @@ func (v *VersionedSignedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIn } return v.Fulu.Message.AggregatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas signed aggregate and proof") + } + + return v.Gloas.Message.AggregatorIndex, nil default: return 0, errors.New("unknown version for signed aggregate and proof") } @@ -85,7 +92,7 @@ func (v *VersionedSignedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIn // IsEmpty returns true if there is no aggregate and proof. func (v *VersionedSignedAggregateAndProof) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && v.Capella == nil && v.Deneb == nil && - v.Electra == nil && v.Fulu == nil + v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // SelectionProof returns the selection proof of the signed aggregate. @@ -133,6 +140,12 @@ func (v *VersionedSignedAggregateAndProof) SelectionProof() (phase0.BLSSignature } return v.Fulu.Message.SelectionProof, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Message.SelectionProof, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -183,6 +196,12 @@ func (v *VersionedSignedAggregateAndProof) Signature() (phase0.BLSSignature, err } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -233,6 +252,12 @@ func (v *VersionedSignedAggregateAndProof) Slot() (phase0.Slot, error) { } return v.Fulu.Message.Aggregate.Data.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Message.Aggregate.Data.Slot, nil default: return 0, errors.New("unknown version") } @@ -283,6 +308,12 @@ func (v *VersionedSignedAggregateAndProof) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedsignedbeaconblock.go b/spec/versionedsignedbeaconblock.go index e6bef3aec..c11653f99 100644 --- a/spec/versionedsignedbeaconblock.go +++ b/spec/versionedsignedbeaconblock.go @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -34,6 +35,7 @@ type VersionedSignedBeaconBlock struct { Deneb *deneb.SignedBeaconBlock Electra *electra.SignedBeaconBlock Fulu *electra.SignedBeaconBlock + Gloas *gloas.SignedBeaconBlock } // Slot returns the slot of the signed beacon block. @@ -81,6 +83,12 @@ func (v *VersionedSignedBeaconBlock) Slot() (phase0.Slot, error) { } return v.Fulu.Message.Slot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Message.Slot, nil default: return 0, errors.New("unknown version") } @@ -131,12 +139,20 @@ func (v *VersionedSignedBeaconBlock) ProposerIndex() (phase0.ValidatorIndex, err } return v.Fulu.Message.ProposerIndex, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Message.ProposerIndex, nil default: return 0, errors.New("unknown version") } } // ExecutionBlockHash returns the block hash of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ExecutionBlockHash() (phase0.Hash32, error) { switch v.Version { case DataVersionBellatrix: @@ -178,6 +194,16 @@ func (v *VersionedSignedBeaconBlock) ExecutionBlockHash() (phase0.Hash32, error) } return v.Fulu.Message.Body.ExecutionPayload.BlockHash, nil + case DataVersionGloas: + if v.Gloas == nil || + v.Gloas.Message == nil || + v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return phase0.Hash32{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlockHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -225,6 +251,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionBlockNumber() (uint64, error) { } return v.Fulu.Message.Body.ExecutionPayload.BlockNumber, nil + case DataVersionGloas: + return 0, errors.New("no execution block number for gloas block") default: return 0, errors.New("unknown version") } @@ -276,12 +304,16 @@ func (v *VersionedSignedBeaconBlock) ExecutionTransactions() ([]bellatrix.Transa } return v.Fulu.Message.Body.ExecutionPayload.Transactions, nil + case DataVersionGloas: + return nil, errors.New("no execution block transactions for gloas block") default: return nil, errors.New("unknown version") } } // Graffiti returns the graffiti for the block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Graffiti() ([32]byte, error) { switch v.Version { case DataVersionPhase0: @@ -326,6 +358,12 @@ func (v *VersionedSignedBeaconBlock) Graffiti() ([32]byte, error) { } return v.Fulu.Message.Body.Graffiti, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return [32]byte{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.Graffiti, nil default: return [32]byte{}, errors.New("unknown version") } @@ -433,6 +471,20 @@ func (v *VersionedSignedBeaconBlock) Attestations() ([]*VersionedAttestation, er } } + return versionedAttestations, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttestations := make([]*VersionedAttestation, len(v.Gloas.Message.Body.Attestations)) + for i, attestation := range v.Gloas.Message.Body.Attestations { + versionedAttestations[i] = &VersionedAttestation{ + Version: DataVersionGloas, + Gloas: attestation, + } + } + return versionedAttestations, nil default: return nil, errors.New("unknown version") @@ -484,12 +536,20 @@ func (v *VersionedSignedBeaconBlock) Root() (phase0.Root, error) { } return v.Fulu.Message.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } } // BodyRoot returns the body root of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) BodyRoot() (phase0.Root, error) { switch v.Version { case DataVersionPhase0: @@ -534,6 +594,12 @@ func (v *VersionedSignedBeaconBlock) BodyRoot() (phase0.Root, error) { } return v.Fulu.Message.Body.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -584,6 +650,12 @@ func (v *VersionedSignedBeaconBlock) ParentRoot() (phase0.Root, error) { } return v.Fulu.Message.ParentRoot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.ParentRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -634,12 +706,20 @@ func (v *VersionedSignedBeaconBlock) StateRoot() (phase0.Root, error) { } return v.Fulu.Message.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } } // RandaoReveal returns the randao reveal of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) { switch v.Version { case DataVersionPhase0: @@ -684,12 +764,20 @@ func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) } return v.Fulu.Message.Body.RANDAOReveal, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return phase0.BLSSignature{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.RANDAOReveal, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } } // ETH1Data returns the eth1 data of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { switch v.Version { case DataVersionPhase0: @@ -734,12 +822,20 @@ func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { } return v.Fulu.Message.Body.ETH1Data, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.ETH1Data, nil default: return nil, errors.New("unknown version") } } // Deposits returns the deposits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { switch v.Version { case DataVersionPhase0: @@ -784,12 +880,20 @@ func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { } return v.Fulu.Message.Body.Deposits, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.Deposits, nil default: return nil, errors.New("unknown version") } } // VoluntaryExits returns the voluntary exits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) VoluntaryExits() ([]*phase0.SignedVoluntaryExit, error) { switch v.Version { case DataVersionPhase0: @@ -834,6 +938,12 @@ func (v *VersionedSignedBeaconBlock) VoluntaryExits() ([]*phase0.SignedVoluntary } return v.Fulu.Message.Body.VoluntaryExits, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.VoluntaryExits, nil default: return nil, errors.New("unknown version") } @@ -941,6 +1051,20 @@ func (v *VersionedSignedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSla } } + return versionedAttesterSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttesterSlashings := make([]VersionedAttesterSlashing, len(v.Gloas.Message.Body.AttesterSlashings)) + for i, attesterSlashing := range v.Gloas.Message.Body.AttesterSlashings { + versionedAttesterSlashings[i] = VersionedAttesterSlashing{ + Version: DataVersionGloas, + Gloas: attesterSlashing, + } + } + return versionedAttesterSlashings, nil default: return nil, errors.New("unknown version") @@ -948,6 +1072,8 @@ func (v *VersionedSignedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSla } // ProposerSlashings returns the proposer slashings of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlashing, error) { switch v.Version { case DataVersionPhase0: @@ -992,6 +1118,12 @@ func (v *VersionedSignedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlas } return v.Fulu.Message.Body.ProposerSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.ProposerSlashings, nil default: return nil, errors.New("unknown version") } @@ -1038,6 +1170,12 @@ func (v *VersionedSignedBeaconBlock) SyncAggregate() (*altair.SyncAggregate, err } return v.Fulu.Message.Body.SyncAggregate, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SyncAggregate, nil default: return nil, errors.New("unknown version") } @@ -1076,6 +1214,12 @@ func (v *VersionedSignedBeaconBlock) BLSToExecutionChanges() ([]*capella.SignedB } return v.Fulu.Message.Body.BLSToExecutionChanges, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.BLSToExecutionChanges, nil default: return nil, errors.New("unknown version") } @@ -1120,6 +1264,8 @@ func (v *VersionedSignedBeaconBlock) Withdrawals() ([]*capella.Withdrawal, error } return v.Fulu.Message.Body.ExecutionPayload.Withdrawals, nil + case DataVersionGloas: + return nil, errors.New("no execution payload withdrawals for gloas block") default: return nil, errors.New("unknown version") } @@ -1154,6 +1300,14 @@ func (v *VersionedSignedBeaconBlock) BlobKZGCommitments() ([]deneb.KZGCommitment } return v.Fulu.Message.Body.BlobKZGCommitments, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlobKZGCommitments, nil default: return nil, errors.New("unknown version") } @@ -1186,6 +1340,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionRequests() (*electra.ExecutionRequ } return v.Fulu.Message.Body.ExecutionRequests, nil + case DataVersionGloas: + return nil, errors.New("gloas block does not have execution requests") default: return nil, errors.New("unknown version") } @@ -1232,6 +1388,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionPayload() (*VersionedExecutionPayl } versionedExecutionPayload.Fulu = v.Fulu.Message.Body.ExecutionPayload + case DataVersionGloas: + return nil, errors.New("no execution payload in gloas") default: return nil, errors.New("unknown version") } @@ -1284,7 +1442,44 @@ func (v *VersionedSignedBeaconBlock) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } } + +// SignedExecutionPayloadBid returns the execution payload bid of the beacon block. +func (v *VersionedSignedBeaconBlock) SignedExecutionPayloadBid() (*VersionedSignedExecutionPayloadBid, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no signed execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no signed execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no signed execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no signed execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no signed execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no signed execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no signed execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return &VersionedSignedExecutionPayloadBid{ + Version: DataVersionGloas, + Gloas: v.Gloas.Message.Body.SignedExecutionPayloadBid, + }, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedsignedexecutionpayloadbid.go b/spec/versionedsignedexecutionpayloadbid.go new file mode 100644 index 000000000..02da5deff --- /dev/null +++ b/spec/versionedsignedexecutionpayloadbid.go @@ -0,0 +1,355 @@ +// Copyright © 2021 - 2024 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedSignedExecutionPayloadBid contains a versioned signed execution payload bid. +type VersionedSignedExecutionPayloadBid struct { + Version DataVersion + Gloas *gloas.SignedExecutionPayloadBid +} + +// String returns a string version of the structure. +func (v *VersionedSignedExecutionPayloadBid) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + + return "unknown version" + } +} + +// ParentBlockHash returns the parent block hash of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ParentBlockHash() (phase0.Hash32, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Hash32{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Hash32{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Hash32{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Hash32{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Hash32{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Hash32{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Hash32{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ParentBlockHash, nil + default: + return phase0.Hash32{}, errors.New("unknown version") + } +} + +// ParentBlockRoot returns the parent block root of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ParentBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ParentBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// BlockHash returns the block hash of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BlockHash() (phase0.Hash32, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Hash32{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Hash32{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Hash32{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Hash32{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Hash32{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Hash32{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Hash32{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BlockHash, nil + default: + return phase0.Hash32{}, errors.New("unknown version") + } +} + +// FeeRecipient returns the fee recipient of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) FeeRecipient() (bellatrix.ExecutionAddress, error) { + switch v.Version { + case DataVersionPhase0: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return bellatrix.ExecutionAddress{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.FeeRecipient, nil + default: + return bellatrix.ExecutionAddress{}, errors.New("unknown version") + } +} + +// GasLimit returns the gas limit of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) GasLimit() (uint64, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.GasLimit, nil + default: + return 0, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// Slot returns the slot of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Slot() (phase0.Slot, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.Slot, nil + default: + return 0, errors.New("unknown version") + } +} + +// Value returns the value of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Value() (phase0.Gwei, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.Value, nil + default: + return 0, errors.New("unknown version") + } +} + +// ExecutionPayment returns the execution payment of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ExecutionPayment() (phase0.Gwei, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ExecutionPayment, nil + default: + return 0, errors.New("unknown version") + } +} + +// BlobKZGCommitments returns the blob kzg commitments of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BlobKZGCommitments() ([]deneb.KZGCommitment, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BlobKZGCommitments, nil + default: + return nil, errors.New("unknown version") + } +} + +// Signature returns the signature of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Signature() (phase0.BLSSignature, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.BLSSignature{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.BLSSignature{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.BLSSignature{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.BLSSignature{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.BLSSignature{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.BLSSignature{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.BLSSignature{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Signature, nil + default: + return phase0.BLSSignature{}, errors.New("unknown version") + } +} diff --git a/spec/versionedsignedexecutionpayloadenvelope.go b/spec/versionedsignedexecutionpayloadenvelope.go new file mode 100644 index 000000000..05f12d054 --- /dev/null +++ b/spec/versionedsignedexecutionpayloadenvelope.go @@ -0,0 +1,251 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedSignedExecutionPayloadEnvelope contains a versioned signed +// execution payload envelope (EIP-7732). The envelope is a gloas-onwards +// container; this wrapper exists so callers can treat it uniformly with +// other versioned types. +type VersionedSignedExecutionPayloadEnvelope struct { + Version DataVersion + Gloas *gloas.SignedExecutionPayloadEnvelope +} + +// IsEmpty returns true if no fork-specific envelope is populated. +func (v *VersionedSignedExecutionPayloadEnvelope) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedSignedExecutionPayloadEnvelope) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Message returns the inner ExecutionPayloadEnvelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Message() (*gloas.ExecutionPayloadEnvelope, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message, nil + default: + return nil, errors.New("unknown version") + } +} + +// Payload returns the execution payload contained in the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Payload() (*gloas.ExecutionPayload, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.Payload, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionRequests returns the execution requests contained in the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) ExecutionRequests() (*VersionedExecutionRequests, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return &VersionedExecutionRequests{ + Version: DataVersionGloas, + Gloas: v.Gloas.Message.ExecutionRequests, + }, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// BeaconBlockRoot returns the beacon block root of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) BeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.BeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// ParentBeaconBlockRoot returns the parent beacon block root of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) ParentBeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.ParentBeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// Signature returns the signature on the envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Signature() (phase0.BLSSignature, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Signature, nil + default: + return phase0.BLSSignature{}, errors.New("unknown version") + } +} From 42f3afaa6820edf51356c064496be7efc5513013 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 23 Jul 2026 14:26:13 +0200 Subject: [PATCH 02/37] address local review findings (test coverage + cosmetic nits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from /review-local (all reviewers APPROVED / SPEC COMPLIANT; only non-blocking findings remained): - spec/gloas/attestation_test.go: the ported test instantiated electra.Attestation instead of gloas.Attestation (inherited verbatim from the fork) — it passed but gave zero coverage of gloas.Attestation's own CommitteeIndex/AggregateValidatorIndex/MarshalSSZ. Swapped to gloas.Attestation; all three tests now genuinely exercise the gloas type (incl. the progressive-bitlist SSZ path). Flagged by both spec-reviewer and code-quality-reviewer. - spec/versionedsignedaggregateandproof.go: lowercased the stray "no Gloas ..." error string to "no gloas ..." for consistency with sibling arms. - spec/versionedsignedexecutionpayloadbid.go: corrected the copyright header (2021-2024 carried over from versionedsignedbeaconblock.go) to 2026 for this new file. - .golangci.yml: dropped the redundant `- lll` from the gloas line-length exclusion (lll is globally disabled; only revive's line-length-limit fires). Verified: go build ./...; go test ./... (2168); changed packages golangci-lint clean. Not fixed here (captured as follow-up / out of scope): - security-reviewer MEDIUM: negative make() panic on malformed transaction JSON in spec/gloas/executionpayload_json.go (verbatim copy of the pre-existing spec/deneb pattern; a library-wide latent DoS, not a gloas regression). Should be fixed uniformly across deneb/electra/fulu/gloas in a dedicated task. --- .golangci.yml | 1 - spec/gloas/attestation_test.go | 8 ++++---- spec/versionedsignedaggregateandproof.go | 2 +- spec/versionedsignedexecutionpayloadbid.go | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 8e395ffa0..0302bf53e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -188,7 +188,6 @@ linters: - path: (spec|api/v1)/gloas/ text: "line is [0-9]+ characters" linters: - - lll - revive # Formatters: Code formatting tools formatters: diff --git a/spec/gloas/attestation_test.go b/spec/gloas/attestation_test.go index 4bbb9bf7c..043df8775 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/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/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/versionedsignedaggregateandproof.go b/spec/versionedsignedaggregateandproof.go index 7de312928..c0147e7fe 100644 --- a/spec/versionedsignedaggregateandproof.go +++ b/spec/versionedsignedaggregateandproof.go @@ -80,7 +80,7 @@ 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 diff --git a/spec/versionedsignedexecutionpayloadbid.go b/spec/versionedsignedexecutionpayloadbid.go index 02da5deff..c3df90603 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 From 6380a91b3b8dfe346491964a3c66f62092235517 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 23 Jul 2026 15:58:06 +0200 Subject: [PATCH 03/37] enable attgo current-year linter and refresh copyright headers to 2026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns on the attgo-linter current-year check (enable_current_year: true) in .golangci.yml and brings every copyright header the check flags up to date. - New spec/gloas files (ported from the fork) and spec/version.go: header year set to 2026 (new-file rule). - Pre-existing spec files touched by this branch: header extended to a " - 2026" range, preserving the original authorship year (modified-file rule). - .gitignore: ignore the custom-gcl binary produced by "golangci-lint custom" (the .custom-gcl.yml build config stays tracked). The current-year check is git-aware and only runs through the custom-gcl binary (it is a plugin module); the standard golangci-lint binary does not include it. These are header-only edits — no code or generated SSZ output changed. Files changed: .golangci.yml, .gitignore, and 66 copyright headers under spec/ and spec/gloas/. Verified: golangci-lint custom && ./custom-gcl run -> 0 issues; go build ./... clean; full test suite (2168 tests) green. --- .gitignore | 3 +++ .golangci.yml | 2 +- spec/gloas/aggregateandproof.go | 2 +- spec/gloas/attestation.go | 2 +- spec/gloas/attesterslashing.go | 2 +- spec/gloas/beaconblock.go | 2 +- spec/gloas/beaconblock_json.go | 2 +- spec/gloas/beaconblock_yaml.go | 2 +- spec/gloas/beaconblockbody.go | 2 +- spec/gloas/beaconblockbody_json.go | 2 +- spec/gloas/beaconblockbody_yaml.go | 2 +- spec/gloas/beaconstate.go | 2 +- spec/gloas/beaconstate_json.go | 2 +- spec/gloas/beaconstate_yaml.go | 2 +- spec/gloas/builder.go | 2 +- spec/gloas/builderindex.go | 2 +- spec/gloas/builderpendingpayment.go | 2 +- spec/gloas/builderpendingwithdrawal.go | 2 +- spec/gloas/executionpayload.go | 2 +- spec/gloas/executionpayload_json.go | 2 +- spec/gloas/executionpayload_yaml.go | 2 +- spec/gloas/executionpayloadbid.go | 2 +- spec/gloas/executionpayloadbid_json.go | 2 +- spec/gloas/executionpayloadbid_yaml.go | 2 +- spec/gloas/executionpayloadenvelope.go | 2 +- spec/gloas/executionpayloadenvelope_json.go | 2 +- spec/gloas/executionpayloadenvelope_yaml.go | 2 +- spec/gloas/generate.go | 2 +- spec/gloas/indexedattestation.go | 2 +- spec/gloas/indexedpayloadattestation.go | 2 +- spec/gloas/indexedpayloadattestation_json.go | 2 +- spec/gloas/indexedpayloadattestation_yaml.go | 2 +- spec/gloas/payloadattestation.go | 2 +- spec/gloas/payloadattestation_json.go | 2 +- spec/gloas/payloadattestation_yaml.go | 2 +- spec/gloas/payloadattestationdata.go | 2 +- spec/gloas/payloadattestationdata_json.go | 2 +- spec/gloas/payloadattestationdata_yaml.go | 2 +- spec/gloas/payloadattestationmessage.go | 2 +- spec/gloas/payloadattestationmessage_json.go | 2 +- spec/gloas/payloadattestationmessage_yaml.go | 2 +- spec/gloas/proposerpreferences.go | 2 +- spec/gloas/proposerpreferences_json.go | 2 +- spec/gloas/proposerpreferences_yaml.go | 2 +- spec/gloas/signedaggregateandproof.go | 2 +- spec/gloas/signedbeaconblock.go | 2 +- spec/gloas/signedbeaconblock_json.go | 2 +- spec/gloas/signedbeaconblock_yaml.go | 2 +- spec/gloas/signedexecutionpayloadbid.go | 2 +- spec/gloas/signedexecutionpayloadbid_json.go | 2 +- spec/gloas/signedexecutionpayloadbid_yaml.go | 2 +- spec/gloas/signedexecutionpayloadenvelope.go | 2 +- spec/gloas/signedexecutionpayloadenvelope_json.go | 2 +- spec/gloas/signedexecutionpayloadenvelope_yaml.go | 2 +- spec/gloas/signedproposerpreferences.go | 2 +- spec/gloas/signedproposerpreferences_json.go | 2 +- spec/gloas/signedproposerpreferences_yaml.go | 2 +- spec/version.go | 2 +- spec/version/builderversion.go | 2 +- spec/version/dataversion.go | 2 +- spec/versionedattestation.go | 2 +- spec/versionedattesterslashing.go | 2 +- spec/versionedbeaconblock.go | 2 +- spec/versionedbeaconblockbody.go | 2 +- spec/versionedexecutionpayload.go | 2 +- spec/versionedindexedattestation.go | 2 +- spec/versionedsignedaggregateandproof.go | 2 +- spec/versionedsignedbeaconblock.go | 2 +- 68 files changed, 70 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 641bc22c7..c5634b157 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ # Test binary, build with `go test -c` *.test +# Custom golangci-lint binary built via `golangci-lint custom` (config: .custom-gcl.yml) +custom-gcl + # Output of the go coverage tool, specifically when used with LiteIDE *.out coverage.html diff --git a/.golangci.yml b/.golangci.yml index 0302bf53e..5765ba8f0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -154,7 +154,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 diff --git a/spec/gloas/aggregateandproof.go b/spec/gloas/aggregateandproof.go index 112d0eeba..f2ea09e73 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 cd28b2cf9..145aea17b 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 diff --git a/spec/gloas/attesterslashing.go b/spec/gloas/attesterslashing.go index 141d8ed54..0d37cddfc 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 97042d5ee..4e4330662 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 5b5ced550..958dcdef5 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 0dadf51e0..96e556927 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 diff --git a/spec/gloas/beaconblockbody.go b/spec/gloas/beaconblockbody.go index 1897d1631..dfab3776d 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 diff --git a/spec/gloas/beaconblockbody_json.go b/spec/gloas/beaconblockbody_json.go index 0af42007d..d779fe85d 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 diff --git a/spec/gloas/beaconblockbody_yaml.go b/spec/gloas/beaconblockbody_yaml.go index 3044be073..3fc8c73a7 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 1a93244df..1a7c40fc6 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 diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go index 9719edf8c..ffdf724fc 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 diff --git a/spec/gloas/beaconstate_yaml.go b/spec/gloas/beaconstate_yaml.go index 4e7d5e01f..f4664d7bc 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 422b7d65f..f2b9b7e0d 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 diff --git a/spec/gloas/builderindex.go b/spec/gloas/builderindex.go index ada3a004b..ceb4b8d7f 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 diff --git a/spec/gloas/builderpendingpayment.go b/spec/gloas/builderpendingpayment.go index bb48ef96e..0e155071d 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 diff --git a/spec/gloas/builderpendingwithdrawal.go b/spec/gloas/builderpendingwithdrawal.go index ee4fca634..5e978a122 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 diff --git a/spec/gloas/executionpayload.go b/spec/gloas/executionpayload.go index 20ab67f11..4c2b3d85f 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 diff --git a/spec/gloas/executionpayload_json.go b/spec/gloas/executionpayload_json.go index f12b9ffc1..1a66db207 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 diff --git a/spec/gloas/executionpayload_yaml.go b/spec/gloas/executionpayload_yaml.go index 18b220b18..bae4e7c0c 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 diff --git a/spec/gloas/executionpayloadbid.go b/spec/gloas/executionpayloadbid.go index 22b3f649a..2336d4dad 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 diff --git a/spec/gloas/executionpayloadbid_json.go b/spec/gloas/executionpayloadbid_json.go index 02bc74307..958452bca 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 diff --git a/spec/gloas/executionpayloadbid_yaml.go b/spec/gloas/executionpayloadbid_yaml.go index d81917532..e3a972380 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 diff --git a/spec/gloas/executionpayloadenvelope.go b/spec/gloas/executionpayloadenvelope.go index 7a9cc484c..8a67b7858 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 diff --git a/spec/gloas/executionpayloadenvelope_json.go b/spec/gloas/executionpayloadenvelope_json.go index 1485b7712..cd53d5d64 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 diff --git a/spec/gloas/executionpayloadenvelope_yaml.go b/spec/gloas/executionpayloadenvelope_yaml.go index 88a561e1f..11b857dd4 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 diff --git a/spec/gloas/generate.go b/spec/gloas/generate.go index e23b9a9fb..b3dc24402 100644 --- a/spec/gloas/generate.go +++ b/spec/gloas/generate.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/indexedattestation.go b/spec/gloas/indexedattestation.go index 5c30472c8..3183e6b97 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 diff --git a/spec/gloas/indexedpayloadattestation.go b/spec/gloas/indexedpayloadattestation.go index 2be803ebe..55114400a 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 diff --git a/spec/gloas/indexedpayloadattestation_json.go b/spec/gloas/indexedpayloadattestation_json.go index 6781a55e6..f4abd1b95 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 diff --git a/spec/gloas/indexedpayloadattestation_yaml.go b/spec/gloas/indexedpayloadattestation_yaml.go index ed540e283..a50656804 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 diff --git a/spec/gloas/payloadattestation.go b/spec/gloas/payloadattestation.go index 88f2d0d7f..a59843447 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 diff --git a/spec/gloas/payloadattestation_json.go b/spec/gloas/payloadattestation_json.go index e94c67d74..d947d4c13 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 diff --git a/spec/gloas/payloadattestation_yaml.go b/spec/gloas/payloadattestation_yaml.go index 3056bbb16..fafd7aed6 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 fbcde0b26..e9199aceb 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 7235098e1..adcd0481c 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 diff --git a/spec/gloas/payloadattestationdata_yaml.go b/spec/gloas/payloadattestationdata_yaml.go index 43d364e54..25250aa9f 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 diff --git a/spec/gloas/payloadattestationmessage.go b/spec/gloas/payloadattestationmessage.go index 8435774c2..a584d8dca 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 f6d24e299..069560857 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 diff --git a/spec/gloas/payloadattestationmessage_yaml.go b/spec/gloas/payloadattestationmessage_yaml.go index 2f452d2e2..88dfe178c 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 diff --git a/spec/gloas/proposerpreferences.go b/spec/gloas/proposerpreferences.go index 172833856..2a0ea0d9f 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 07f961f36..175d7adea 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 diff --git a/spec/gloas/proposerpreferences_yaml.go b/spec/gloas/proposerpreferences_yaml.go index 5e057e46f..775f588a8 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 diff --git a/spec/gloas/signedaggregateandproof.go b/spec/gloas/signedaggregateandproof.go index 6d615ae69..52256805d 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 3bb40583c..0da2d78f1 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 dd4ce7d0f..dc2feb10e 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 diff --git a/spec/gloas/signedbeaconblock_yaml.go b/spec/gloas/signedbeaconblock_yaml.go index a00b0c683..555699ef8 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 diff --git a/spec/gloas/signedexecutionpayloadbid.go b/spec/gloas/signedexecutionpayloadbid.go index 0c5d25252..a047b8edb 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 4e0a26748..3b8cfbef1 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 diff --git a/spec/gloas/signedexecutionpayloadbid_yaml.go b/spec/gloas/signedexecutionpayloadbid_yaml.go index d5adfadaa..e0d5a7bb9 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 d0634900a..70eb7ebd6 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 9eec83eb2..05809ed90 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 diff --git a/spec/gloas/signedexecutionpayloadenvelope_yaml.go b/spec/gloas/signedexecutionpayloadenvelope_yaml.go index 7839dc556..c843bb08f 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 25f18a66c..a89646d4e 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 fdc193ff7..1c6706749 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 diff --git a/spec/gloas/signedproposerpreferences_yaml.go b/spec/gloas/signedproposerpreferences_yaml.go index 3f1c8ea3a..dbd1848aa 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/version.go b/spec/version.go index d54427cd8..a9dd4f8db 100644 --- a/spec/version.go +++ b/spec/version.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/version/builderversion.go b/spec/version/builderversion.go index 4a7917d36..0be76c408 100644 --- a/spec/version/builderversion.go +++ b/spec/version/builderversion.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 diff --git a/spec/version/dataversion.go b/spec/version/dataversion.go index 2a1e91b8b..f1ecf9114 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 diff --git a/spec/versionedattestation.go b/spec/versionedattestation.go index 48a5471e1..f367119f4 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 diff --git a/spec/versionedattesterslashing.go b/spec/versionedattesterslashing.go index c76981301..e9613f1cb 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 diff --git a/spec/versionedbeaconblock.go b/spec/versionedbeaconblock.go index f55a5c774..e0a579a6a 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 diff --git a/spec/versionedbeaconblockbody.go b/spec/versionedbeaconblockbody.go index d212bc652..1ebf1f8b4 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/versionedexecutionpayload.go b/spec/versionedexecutionpayload.go index 36804d90c..d628eecdc 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 diff --git a/spec/versionedindexedattestation.go b/spec/versionedindexedattestation.go index 49c779d6d..06fa31d21 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 diff --git a/spec/versionedsignedaggregateandproof.go b/spec/versionedsignedaggregateandproof.go index c0147e7fe..9627c46e0 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 diff --git a/spec/versionedsignedbeaconblock.go b/spec/versionedsignedbeaconblock.go index c11653f99..d25e2e1ba 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 From ed3eb62466f17cce251a88b79affb50adc05524d Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 23 Jul 2026 19:39:03 +0200 Subject: [PATCH 04/37] port spec/gloas ePBS transport surface (endpoints, events, client parity) Additive port of the Gloas (Glamsterdam devnet-7) ePBS transport surface from the ethpandaops fork, imports rewritten to attestantio, additive only. What landed: - 3 ePBS HTTP endpoints in http/: SignedExecutionPayloadEnvelope (GET), SubmitExecutionPayloadBid (POST), SubmitExecutionPayloadEnvelope (POST, stateless Contents form) + their api/ opts types. - 3 provider interfaces in service.go: ExecutionPayloadProvider, ExecutionPayloadEnvelopeSubmitter, ExecutionPayloadBidSubmitter. - 7 new SSE event topics with typed handlers in http/events.go (execution_payload, execution_payload_available, execution_payload_bid, execution_payload_gossip, fast_confirmation, payload_attestation_message, proposer_preferences); handler fields/types in api/eventsopts.go; 3 new api/v1 event payload types (ExecutionPayloadEvent, ExecutionPayloadAvailableEvent, FastConfirmationEvent) + the unmarshalVersionedEventData helper (accepts both {version,data}-wrapped and bare payloads). - multi/mock/testclients parity for the 3 endpoints. - New HTTP tests for the 3 ePBS endpoints (success + error paths), 3 api/v1 event round-trip tests, and TestEventHandler extended with the 7 new topics (wrapped + bare). - http/main_test.go: shared test service now built with WithCustomSpecSupport(true) (the interim devnet runs the minimal preset). Key decisions: - Dropped the fork's spec/all "Agnostic*" endpoint/opts/interface variants (spec/all not adopted) and all Heze arms (Heze out of scope); the non-agnostic variants use the versioned wrappers ported earlier. - Added a local dynSSZForRequest helper mirroring the repo's existing inline dynssz pattern (GetGlobalDynSsz for mainnet, NewDynSsz(spec) for custom presets) rather than the fork's Service-cached version, which depends on fields this tree lacks. - Added ePBS methods to testclients erroring/sleepy for provider parity even though the fork ships none (networkutil excluded - no provider methods). - Two wire details intentionally DIVERGE from the (stale) fork to match the live beacon-APIs spec (master), verified against the upstream spec: * submit-envelope header is Eth-Blob-Data-Included:true (fork used the removed Eth-Execution-Payload-Blinded:false). * FastConfirmationEvent carries current_slot (fork omitted it), parsed tolerantly. Verified: - go build ./...; gosilent test ./... (2201); golangci-lint via custom-gcl (attgo current-year plugin) 0 issues; go generate ./spec/gloas byte-identical. - Against a live Gloas devnet (Lighthouse, minimal preset): the 3 ePBS endpoint tests pass (incl. a real envelope fetch at head and both submit ReachesServer cases) and TestEventHandler passes for all 7 new topics. Files changed: - new: http/{signedexecutionpayloadenvelope,submitexecutionpayloadbid, submitexecutionpayloadenvelope}.go (+ _test.go); api/{signed...,submit...} opts.go (3); api/v1/{executionpayloadevent,executionpayloadavailableevent, fastconfirmationevent}.go (+ _test.go); multi/*.go (3); mock/*.go (3). - modified: service.go, api/eventsopts.go, http/events.go, http/events_internal_test.go, http/main_test.go, mock/service.go, testclients/{erroring,sleepy}.go. Blockers / notes for next iteration: - Existing versioned HTTP endpoints (beacon state, and by extension the validators endpoint, blocks, proposals) lack a gloas arm in their version switch, so they fail against a gloas endpoint with "unhandled state version gloas" (http/beaconstate.go). Out of scope here (transport only); needs a dedicated "gloas-enable existing endpoints" slice. - spec/gloas/executionpayload_json.go MarshalJSON panics on a nil BaseFeePerGas (unguarded .Dec()); the SSZ marshaler already guards it. JSON path only; latent. Same robustness family as the deneb-inherited make() DoS. --- api/eventsopts.go | 40 ++- api/signedexecutionpayloadenvelopeopts.go | 22 ++ api/submitexecutionpayloadbidopts.go | 28 +++ api/submitexecutionpayloadenvelopeopts.go | 45 ++++ api/v1/executionpayloadavailableevent.go | 86 +++++++ api/v1/executionpayloadavailableevent_test.go | 91 +++++++ api/v1/executionpayloadevent.go | 132 ++++++++++ api/v1/executionpayloadevent_test.go | 122 +++++++++ api/v1/fastconfirmationevent.go | 107 ++++++++ api/v1/fastconfirmationevent_test.go | 110 ++++++++ http/events.go | 236 +++++++++++++++++- http/events_internal_test.go | 130 ++++++++++ http/main_test.go | 14 +- http/signedexecutionpayloadenvelope.go | 134 ++++++++++ http/signedexecutionpayloadenvelope_test.go | 72 ++++++ http/submitexecutionpayloadbid.go | 111 ++++++++ http/submitexecutionpayloadbid_test.go | 80 ++++++ http/submitexecutionpayloadenvelope.go | 166 ++++++++++++ http/submitexecutionpayloadenvelope_test.go | 84 +++++++ mock/service.go | 71 +++--- mock/signedexecutionpayloadenvelope.go | 59 +++++ mock/submitexecutionpayloadbid.go | 25 ++ mock/submitexecutionpayloadenvelope.go | 25 ++ multi/signedexecutionpayloadenvelope.go | 49 ++++ multi/submitexecutionpayloadbid.go | 37 +++ multi/submitexecutionpayloadenvelope.go | 37 +++ service.go | 35 ++- testclients/erroring.go | 49 +++- testclients/sleepy.go | 43 +++- 29 files changed, 2199 insertions(+), 41 deletions(-) create mode 100644 api/signedexecutionpayloadenvelopeopts.go create mode 100644 api/submitexecutionpayloadbidopts.go create mode 100644 api/submitexecutionpayloadenvelopeopts.go create mode 100644 api/v1/executionpayloadavailableevent.go create mode 100644 api/v1/executionpayloadavailableevent_test.go create mode 100644 api/v1/executionpayloadevent.go create mode 100644 api/v1/executionpayloadevent_test.go create mode 100644 api/v1/fastconfirmationevent.go create mode 100644 api/v1/fastconfirmationevent_test.go create mode 100644 http/signedexecutionpayloadenvelope.go create mode 100644 http/signedexecutionpayloadenvelope_test.go create mode 100644 http/submitexecutionpayloadbid.go create mode 100644 http/submitexecutionpayloadbid_test.go create mode 100644 http/submitexecutionpayloadenvelope.go create mode 100644 http/submitexecutionpayloadenvelope_test.go create mode 100644 mock/signedexecutionpayloadenvelope.go create mode 100644 mock/submitexecutionpayloadbid.go create mode 100644 mock/submitexecutionpayloadenvelope.go create mode 100644 multi/signedexecutionpayloadenvelope.go create mode 100644 multi/submitexecutionpayloadbid.go create mode 100644 multi/submitexecutionpayloadenvelope.go diff --git a/api/eventsopts.go b/api/eventsopts.go index 8814ed58d..3edd28bc1 100644 --- a/api/eventsopts.go +++ b/api/eventsopts.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// Copyright © 2025 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -53,12 +54,28 @@ type EventsOpts struct { ContributionAndProofHandler ContributionAndProofEventHandlerFunc // DataColumnSidecarHandler is a handler for the data_column_sidecar event. DataColumnSidecarHandler DataColumnSidecarEventHandlerFunc + // ExecutionPayloadHandler is a handler for the execution_payload event + // (SignedExecutionPayloadEnvelope successfully imported on the fork-choice). + ExecutionPayloadHandler ExecutionPayloadEventHandlerFunc + // ExecutionPayloadAvailableHandler is a handler for the execution_payload_available event. + ExecutionPayloadAvailableHandler ExecutionPayloadAvailableEventHandlerFunc + // ExecutionPayloadBidHandler is a handler for the execution_payload_bid event. + ExecutionPayloadBidHandler ExecutionPayloadBidEventHandlerFunc + // ExecutionPayloadGossipHandler is a handler for the execution_payload_gossip event + // (SignedExecutionPayloadEnvelope passed gossip validation). + ExecutionPayloadGossipHandler ExecutionPayloadGossipEventHandlerFunc + // FastConfirmationHandler is a handler for the fast_confirmation event. + FastConfirmationHandler FastConfirmationEventHandlerFunc // FinalizedCheckpointHandler is a handler for the finalized_checkpoint event. FinalizedCheckpointHandler FinalizedCheckpointEventHandlerFunc // HeadHandler is a handler for the head event. HeadHandler HeadEventHandlerFunc + // PayloadAttestationMessageHandler is a handler for the payload_attestation_message event. + PayloadAttestationMessageHandler PayloadAttestationMessageEventHandlerFunc // PayloadAttributesHandler is a handler for the payload_attributes event. PayloadAttributesHandler PayloadAttributesEventHandlerFunc + // ProposerPreferencesHandler is a handler for the proposer_preferences event. + ProposerPreferencesHandler ProposerPreferencesEventHandlerFunc // ProposerSlashingHandler is a handler for the proposer_slashing event. ProposerSlashingHandler ProposerSlashingEventHandlerFunc // SingleAttestationHandler is a handler for the single_attestation event. @@ -114,3 +131,24 @@ type VoluntaryExitEventHandlerFunc func(context.Context, *phase0.SignedVoluntary // DataColumnSidecarEventHandlerFunc is the handler for data_column_sidecar events. type DataColumnSidecarEventHandlerFunc func(context.Context, *apiv1.DataColumnSidecarEvent) + +// ExecutionPayloadEventHandlerFunc is the handler for execution_payload events. +type ExecutionPayloadEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) + +// ExecutionPayloadAvailableEventHandlerFunc is the handler for execution_payload_available events. +type ExecutionPayloadAvailableEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) + +// ExecutionPayloadBidEventHandlerFunc is the handler for execution_payload_bid events. +type ExecutionPayloadBidEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadBid) + +// ExecutionPayloadGossipEventHandlerFunc is the handler for execution_payload_gossip events. +type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) + +// FastConfirmationEventHandlerFunc is the handler for fast_confirmation events. +type FastConfirmationEventHandlerFunc func(context.Context, *apiv1.FastConfirmationEvent) + +// PayloadAttestationMessageEventHandlerFunc is the handler for payload_attestation_message events. +type PayloadAttestationMessageEventHandlerFunc func(context.Context, *gloas.PayloadAttestationMessage) + +// ProposerPreferencesEventHandlerFunc is the handler for proposer_preferences events. +type ProposerPreferencesEventHandlerFunc func(context.Context, *gloas.SignedProposerPreferences) diff --git a/api/signedexecutionpayloadenvelopeopts.go b/api/signedexecutionpayloadenvelopeopts.go new file mode 100644 index 000000000..271918ce0 --- /dev/null +++ b/api/signedexecutionpayloadenvelopeopts.go @@ -0,0 +1,22 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +// SignedExecutionPayloadEnvelopeOpts are the options for obtaining signed execution payload envelopes. +type SignedExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // Block is the ID of the block which the data is obtained. + Block string +} diff --git a/api/submitexecutionpayloadbidopts.go b/api/submitexecutionpayloadbidopts.go new file mode 100644 index 000000000..3b7ff3e31 --- /dev/null +++ b/api/submitexecutionpayloadbidopts.go @@ -0,0 +1,28 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitExecutionPayloadBidOpts are the options for submitting execution +// payload bids. +type SubmitExecutionPayloadBidOpts struct { + Common CommonOpts + + // SignedExecutionPayloadBid is the signed bid to publish. Its Version + // selects the wire schema and the consensus version header. + SignedExecutionPayloadBid *spec.VersionedSignedExecutionPayloadBid +} diff --git a/api/submitexecutionpayloadenvelopeopts.go b/api/submitexecutionpayloadenvelopeopts.go new file mode 100644 index 000000000..a6119cc0d --- /dev/null +++ b/api/submitexecutionpayloadenvelopeopts.go @@ -0,0 +1,45 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + apiv2 "github.com/attestantio/go-eth2-client/api/v2" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" +) + +// SubmitExecutionPayloadEnvelopeOpts are the options for submitting execution +// payload envelopes. The submission always uses the stateless request form +// (SignedExecutionPayloadEnvelopeContents): the signed envelope plus the +// blobs and KZG cell proofs the beacon node needs to build data column +// sidecars. The stateful/blinded form only works when the beacon node cached +// the full envelope from its own block production, so it is not offered here. +type SubmitExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // SignedExecutionPayloadEnvelope is the signed envelope to publish. + // Its Version selects the wire schema and the consensus version header. + SignedExecutionPayloadEnvelope *spec.VersionedSignedExecutionPayloadEnvelope + + // KZGProofs are the cell KZG proofs for the blobs committed to by the + // envelope's payload. + KZGProofs []deneb.KZGProof + + // Blobs are the blobs committed to by the envelope's payload. + Blobs []deneb.Blob + + // BroadcastValidation is the validation required of the consensus node + // before broadcasting the envelope. + BroadcastValidation *apiv2.BroadcastValidation +} diff --git a/api/v1/executionpayloadavailableevent.go b/api/v1/executionpayloadavailableevent.go new file mode 100644 index 000000000..35587ba50 --- /dev/null +++ b/api/v1/executionpayloadavailableevent.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 v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// ExecutionPayloadAvailableEvent is the data for the execution payload available event. +type ExecutionPayloadAvailableEvent struct { + BlockRoot phase0.Root + Slot phase0.Slot +} + +// executionPayloadAvailableEventJSON is the spec representation of the struct. +type executionPayloadAvailableEventJSON struct { + BlockRoot string `json:"block_root"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadAvailableEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadAvailableEventJSON{ + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + Slot: fmt.Sprintf("%d", e.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadAvailableEvent) UnmarshalJSON(input []byte) error { + var err error + + var executionPayloadAvailableEventJSON executionPayloadAvailableEventJSON + if err = json.Unmarshal(input, &executionPayloadAvailableEventJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + if executionPayloadAvailableEventJSON.BlockRoot == "" { + return errors.New("block root missing") + } + 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)) + } + copy(e.BlockRoot[:], block) + if executionPayloadAvailableEventJSON.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(executionPayloadAvailableEventJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadAvailableEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/executionpayloadavailableevent_test.go b/api/v1/executionpayloadavailableevent_test.go new file mode 100644 index 000000000..ccfed0c57 --- /dev/null +++ b/api/v1/executionpayloadavailableevent_test.go @@ -0,0 +1,91 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadAvailableEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.executionPayloadAvailableEventJSON", + }, + { + name: "BlockRootMissing", + input: []byte(`{"slot":"525277"}`), + err: "block root missing", + }, + { + name: "BlockRootInvalid", + input: []byte(`{"block_root":"invalid","slot":"525277"}`), + err: "invalid value for block root: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockRootShort", + input: []byte(`{"block_root":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + err: "incorrect length 31 for block root", + }, + { + name: "BlockRootLong", + input: []byte(`{"block_root":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + err: "incorrect length 33 for block root", + }, + { + name: "SlotMissing", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"-1"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Good", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ExecutionPayloadAvailableEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } +} diff --git a/api/v1/executionpayloadevent.go b/api/v1/executionpayloadevent.go new file mode 100644 index 000000000..a05dc5df5 --- /dev/null +++ b/api/v1/executionpayloadevent.go @@ -0,0 +1,132 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// 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. +type ExecutionPayloadEvent struct { + Slot phase0.Slot + BuilderIndex uint64 + BlockHash phase0.Hash32 + BlockRoot phase0.Root + ExecutionOptimistic bool +} + +// executionPayloadEventJSON is the spec representation of the struct. +type executionPayloadEventJSON struct { + Slot string `json:"slot"` + BuilderIndex string `json:"builder_index"` + BlockHash string `json:"block_hash"` + BlockRoot string `json:"block_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + ExecutionOptimistic: e.ExecutionOptimistic, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +// 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). +func (e *ExecutionPayloadEvent) UnmarshalJSON(input []byte) error { + var data executionPayloadEventJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + + if data.BlockRoot == "" { + return errors.New("block root missing") + } + if err := decodeFixedBytes(e.BlockRoot[:], data.BlockRoot, rootLength, "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.BlockHash != "" { + if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, phase0.Hash32Length, "block hash"); err != nil { + return err + } + } + + e.ExecutionOptimistic = data.ExecutionOptimistic + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly +// wantLen bytes. +func decodeFixedBytes(dst []byte, value string, wantLen int, 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 { + return fmt.Errorf("incorrect length %d for %s", len(decoded), name) + } + copy(dst, decoded) + + return nil +} diff --git a/api/v1/executionpayloadevent_test.go b/api/v1/executionpayloadevent_test.go new file mode 100644 index 000000000..ef917776a --- /dev/null +++ b/api/v1/executionpayloadevent_test.go @@ -0,0 +1,122 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.executionPayloadEventJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"builder_index":"12","block_hash":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","execution_optimistic":false}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockRootMissing", + input: []byte(`{"slot":"4095940"}`), + err: "block root missing", + }, + { + name: "BlockRootInvalid", + input: []byte(`{"slot":"4095940","block_root":"invalid"}`), + err: "invalid value for block root: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockRootShort", + input: []byte(`{"slot":"4095940","block_root":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 31 for block root", + }, + { + name: "BlockRootLong", + input: []byte(`{"slot":"4095940","block_root":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 33 for block root", + }, + { + name: "BuilderIndexInvalid", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","builder_index":"-1"}`), + err: "invalid value for builder index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockHashInvalid", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","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","block_hash":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 31 for block hash", + }, + { + name: "Good", + input: []byte(`{"slot":"4095940","builder_index":"12","block_hash":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","execution_optimistic":true}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ExecutionPayloadEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } + + // Only slot and block_root are required; builder_index and block_hash are + // optional and tolerated when absent. This input omits them, so it does not + // round-trip byte-for-byte (MarshalJSON always emits all five fields); + // assert on the parsed fields instead. + t.Run("GoodOptionalFieldsAbsent", func(t *testing.T) { + var res api.ExecutionPayloadEvent + err := json.Unmarshal([]byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), &res) + require.NoError(t, err) + require.Equal(t, phase0.Slot(4095940), res.Slot) + require.NotEqual(t, phase0.Root{}, res.BlockRoot) + require.Equal(t, uint64(0), res.BuilderIndex) + require.Equal(t, phase0.Hash32{}, res.BlockHash) + require.False(t, res.ExecutionOptimistic) + }) +} diff --git a/api/v1/fastconfirmationevent.go b/api/v1/fastconfirmationevent.go new file mode 100644 index 000000000..06b724192 --- /dev/null +++ b/api/v1/fastconfirmationevent.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 v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// FastConfirmationEvent is the data for the fast confirmation event. Slot and +// Block identify the most recent confirmed block; CurrentSlot is the wall-clock +// slot at which the fast-confirmation algorithm was executed. +type FastConfirmationEvent struct { + Slot phase0.Slot + Block phase0.Root + CurrentSlot phase0.Slot +} + +// fastConfirmationEventJSON is the spec representation of the struct. +type fastConfirmationEventJSON struct { + Slot string `json:"slot"` + Block string `json:"block"` + CurrentSlot string `json:"current_slot"` +} + +// MarshalJSON implements json.Marshaler. +func (e *FastConfirmationEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&fastConfirmationEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + Block: fmt.Sprintf("%#x", e.Block), + CurrentSlot: fmt.Sprintf("%d", e.CurrentSlot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *FastConfirmationEvent) UnmarshalJSON(input []byte) error { + var data fastConfirmationEventJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + e.Slot = phase0.Slot(slot) + + if data.Block == "" { + return errors.New("block missing") + } + + block, err := hex.DecodeString(strings.TrimPrefix(data.Block, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for block") + } + + if len(block) != rootLength { + return fmt.Errorf("incorrect length %d for block", len(block)) + } + + copy(e.Block[:], block) + + // current_slot was added to the spec after slot/block; parse it when + // present but tolerate clients that do not yet emit it. + if data.CurrentSlot != "" { + currentSlot, err := strconv.ParseUint(data.CurrentSlot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for current slot") + } + + e.CurrentSlot = phase0.Slot(currentSlot) + } + + return nil +} + +// String returns a string version of the structure. +func (e *FastConfirmationEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/fastconfirmationevent_test.go b/api/v1/fastconfirmationevent_test.go new file mode 100644 index 000000000..00568d49d --- /dev/null +++ b/api/v1/fastconfirmationevent_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 v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "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"}`), + }, + } + + 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()) + } + }) + } + + // current_slot was added to the spec after slot/block, so clients that do + // not emit it must still parse cleanly (defaulting CurrentSlot to 0). This + // input intentionally omits current_slot, so it does not round-trip + // byte-for-byte (MarshalJSON always emits current_slot); assert on the + // parsed fields instead. + t.Run("GoodNoCurrentSlot", func(t *testing.T) { + var res api.FastConfirmationEvent + err := json.Unmarshal([]byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), &res) + require.NoError(t, err) + require.Equal(t, phase0.Slot(525277), res.Slot) + require.Equal(t, phase0.Slot(0), res.CurrentSlot) + }) +} diff --git a/http/events.go b/http/events.go index 6d8067f2b..10c547af1 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 @@ -32,6 +32,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" @@ -145,12 +146,26 @@ func (*Service) checkEventSpecificHandler(opts *api.EventsOpts, topic string) er 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 "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": @@ -200,12 +215,26 @@ func (s *Service) handleEvent(ctx context.Context, 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 "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": @@ -623,3 +652,208 @@ func (*Service) handleDataColumnSidecarEvent(ctx context.Context, 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") + } +} + +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) 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") + + 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 + } + + switch { + case opts.ProposerPreferencesHandler != nil: + opts.ProposerPreferencesHandler(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) +} diff --git a/http/events_internal_test.go b/http/events_internal_test.go index a8189d60e..5b9561e8b 100644 --- a/http/events_internal_test.go +++ b/http/events_internal_test.go @@ -16,6 +16,7 @@ package http import ( "bytes" "context" + "encoding/json" "fmt" "os" "testing" @@ -24,6 +25,7 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" "github.com/stretchr/testify/require" @@ -163,6 +165,114 @@ func TestEventHandler(t *testing.T) { handler: handler, handled: true, }, + { + name: "ExecutionPayloadWrappedGood", + message: &sse.Event{ + Event: []byte("execution_payload"), + Data: versionWrap([]byte(`{"slot":"1","builder_index":"2","block_hash":"0xaa00000000000000000000000000000000000000000000000000000000000000","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000","execution_optimistic":false}`)), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadBareGood", + message: &sse.Event{ + Event: []byte("execution_payload"), + Data: []byte(`{"slot":"1","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000"}`), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadGossipWrappedGood", + message: &sse.Event{ + Event: []byte("execution_payload_gossip"), + Data: versionWrap([]byte(`{"slot":"1","builder_index":"2","block_hash":"0xaa00000000000000000000000000000000000000000000000000000000000000","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000","execution_optimistic":false}`)), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadGossipBareGood", + message: &sse.Event{ + Event: []byte("execution_payload_gossip"), + Data: []byte(`{"slot":"1","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000"}`), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadAvailableGood", + message: &sse.Event{ + Event: []byte("execution_payload_available"), + Data: []byte(`{"block_root":"0xcc00000000000000000000000000000000000000000000000000000000000000","slot":"3"}`), + }, + handler: handler, + handled: true, + }, + { + name: "FastConfirmationGood", + message: &sse.Event{ + Event: []byte("fast_confirmation"), + Data: []byte(`{"slot":"4","block":"0xdd00000000000000000000000000000000000000000000000000000000000000","current_slot":"5"}`), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadBidWrappedGood", + message: &sse.Event{ + Event: []byte("execution_payload_bid"), + Data: versionWrap(mustEventJSON(t, &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}})), + }, + handler: handler, + handled: true, + }, + { + name: "ExecutionPayloadBidBareGood", + message: &sse.Event{ + Event: []byte("execution_payload_bid"), + Data: mustEventJSON(t, &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}), + }, + handler: handler, + handled: true, + }, + { + name: "PayloadAttestationMessageWrappedGood", + message: &sse.Event{ + Event: []byte("payload_attestation_message"), + Data: versionWrap(mustEventJSON(t, &gloas.PayloadAttestationMessage{Data: &gloas.PayloadAttestationData{}})), + }, + handler: handler, + handled: true, + }, + { + name: "PayloadAttestationMessageBareGood", + message: &sse.Event{ + Event: []byte("payload_attestation_message"), + Data: mustEventJSON(t, &gloas.PayloadAttestationMessage{Data: &gloas.PayloadAttestationData{}}), + }, + handler: handler, + handled: true, + }, + { + name: "ProposerPreferencesWrappedGood", + message: &sse.Event{ + Event: []byte("proposer_preferences"), + Data: versionWrap(mustEventJSON(t, &gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}})), + }, + handler: handler, + handled: true, + }, + { + name: "ProposerPreferencesBareGood", + message: &sse.Event{ + Event: []byte("proposer_preferences"), + Data: mustEventJSON(t, &gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}}), + }, + handler: handler, + handled: true, + }, } // Note: Rate limiting for internal tests would need to be implemented separately @@ -198,3 +308,23 @@ func TestEventHandler(t *testing.T) { }) } } + +// mustEventJSON marshals v to JSON for use as synthetic SSE event data, failing +// the test if marshaling errors. Building gloas event payloads from constructed +// values (rather than hand-written hex) keeps the fixed-length signature and +// root fields valid. +func mustEventJSON(t *testing.T, v any) []byte { + t.Helper() + + data, err := json.Marshal(v) + require.NoError(t, err) + + return data +} + +// versionWrap wraps a bare event payload in the beacon-API +// {"version":"gloas","data":{...}} envelope, exercising the wrapped branch of +// unmarshalVersionedEventData. +func versionWrap(inner []byte) []byte { + return []byte(`{"version":"gloas","data":` + string(inner) + `}`) +} diff --git a/http/main_test.go b/http/main_test.go index ac84cebf9..bc9ee1290 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020 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 @@ -79,6 +79,9 @@ func initGlobalHTTPService() { service, err = http.New(ctx, http.WithTimeout(timeout), http.WithAddress(os.Getenv("HTTP_ADDRESS")), + // Required for the interim Glamsterdam devnet's minimal preset; a + // no-op superset for mainnet-preset endpoints (see ADR-0003). + http.WithCustomSpecSupport(true), http.WithAllowDelayedStart(true), http.WithExtraHeaders(map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", os.Getenv("HTTP_BEARER_TOKEN")), @@ -88,6 +91,9 @@ func initGlobalHTTPService() { service, err = http.New(ctx, http.WithTimeout(timeout), http.WithAddress(os.Getenv("HTTP_ADDRESS")), + // Required for the interim Glamsterdam devnet's minimal preset; a + // no-op superset for mainnet-preset endpoints (see ADR-0003). + http.WithCustomSpecSupport(true), http.WithAllowDelayedStart(true), ) } @@ -130,12 +136,18 @@ func testService(ctx context.Context, t *testing.T) any { service, err = http.New(ctx, http.WithTimeout(timeout), http.WithAddress(os.Getenv("HTTP_ADDRESS")), + // Required for the interim Glamsterdam devnet's minimal preset; a + // no-op superset for mainnet-preset endpoints (see ADR-0003). + http.WithCustomSpecSupport(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")), + // Required for the interim Glamsterdam devnet's minimal preset; a + // no-op superset for mainnet-preset endpoints (see ADR-0003). + http.WithCustomSpecSupport(true), ) } diff --git a/http/signedexecutionpayloadenvelope.go b/http/signedexecutionpayloadenvelope.go new file mode 100644 index 000000000..ea247d7ad --- /dev/null +++ b/http/signedexecutionpayloadenvelope.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" + "maps" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope +// given a block ID and decodes it directly into the per-fork view stored on a +// *spec.VersionedSignedExecutionPayloadEnvelope. No intermediate copy. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + httpResponse, err := s.fetchSignedExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + // The envelope is a gloas-onwards container, so the wire bytes always + // parse into *gloas.SignedExecutionPayloadEnvelope. + envelope := &gloas.SignedExecutionPayloadEnvelope{} + metadata := metadataFromHeaders(httpResponse.headers) + + switch httpResponse.contentType { + case ContentTypeSSZ: + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, err + } + + if err := ds.UnmarshalSSZ(envelope, httpResponse.body); err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) + } + case ContentTypeJSON: + 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, + ) + } + + envelope = decoded + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) + } + + return &api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]{ + Data: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: httpResponse.consensusVersion, + Gloas: envelope, + }, + Metadata: metadata, + }, nil +} + +// fetchSignedExecutionPayloadEnvelope performs the GET request for +// SignedExecutionPayloadEnvelope: validates opts, hits the endpoint, and +// rejects responses for forks where the envelope doesn't apply. +func (s *Service) fetchSignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) (*httpResponse, error) { + if err := s.assertIsActive(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + if opts.Block == "" { + return nil, errors.Join(errors.New("no block specified"), client.ErrInvalidOptions) + } + + endpoint := fmt.Sprintf("/eth/v1/beacon/execution_payload_envelopes/%s", opts.Block) + + httpResponse, err := s.get(ctx, endpoint, "", &opts.Common, true) + if err != nil { + return nil, err + } + + if httpResponse.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("execution payload envelope not available for block version %s", httpResponse.consensusVersion) + } + + return httpResponse, nil +} + +// dynSSZForRequest returns the dynamic-SSZ codec to use for marshaling and +// unmarshaling ePBS payloads. With custom spec support it builds a codec from +// the node's fetched spec (needed for non-mainnet presets); otherwise it +// returns the shared global codec built from the default (mainnet) preset. +func (s *Service) dynSSZForRequest(ctx context.Context) (*dynssz.DynSsz, error) { + if !s.customSpecSupport { + return dynssz.GetGlobalDynSsz(), nil + } + + specs, err := s.Spec(ctx, &api.SpecOpts{}) + if err != nil { + return nil, errors.Join(errors.New("failed to request specs"), err) + } + + return dynssz.NewDynSsz(specs.Data), nil +} diff --git a/http/signedexecutionpayloadenvelope_test.go b/http/signedexecutionpayloadenvelope_test.go new file mode 100644 index 000000000..13c424a01 --- /dev/null +++ b/http/signedexecutionpayloadenvelope_test.go @@ -0,0 +1,72 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestSignedExecutionPayloadEnvelope(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SignedExecutionPayloadEnvelopeOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NoBlock", + opts: &api.SignedExecutionPayloadEnvelopeOpts{}, + err: "no block specified", + }, + { + name: "Head", + opts: &api.SignedExecutionPayloadEnvelopeOpts{Block: "head"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := service.(client.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, test.opts) + switch { + case test.err != "": + require.ErrorContains(t, err, test.err) + default: + if err != nil { + // The execution payload envelope endpoint is Gloas-only and + // the envelope for a given block may not yet be revealed, so + // tolerate a node that cannot serve it. + t.Skipf("execution payload envelope not available: %v", err) + } + + require.NotNil(t, response) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.NotNil(t, response.Data.Gloas) + } + }) + } +} diff --git a/http/submitexecutionpayloadbid.go b/http/submitexecutionpayloadbid.go new file mode 100644 index 000000000..578919878 --- /dev/null +++ b/http/submitexecutionpayloadbid.go @@ -0,0 +1,111 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitExecutionPayloadBid submits a signed execution payload bid for +// gossip broadcast. +func (s *Service) SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if opts.SignedExecutionPayloadBid == nil { + return errors.Join(errors.New("no bid supplied"), client.ErrInvalidOptions) + } + + versioned := opts.SignedExecutionPayloadBid + + var bid any + + switch versioned.Version { + case spec.DataVersionGloas: + if versioned.Gloas == nil { + return errors.Join(errors.New("no gloas bid supplied"), client.ErrInvalidOptions) + } + + bid = versioned.Gloas + default: + return errors.Join( + fmt.Errorf("unsupported bid version %s", versioned.Version), + client.ErrInvalidOptions, + ) + } + + return s.postExecutionPayloadBid(ctx, &opts.Common, versioned.Version, bid) +} + +// postExecutionPayloadBid marshals the bid to the negotiated content type +// (SSZ unless JSON is enforced) and performs the POST. +func (s *Service) postExecutionPayloadBid(ctx context.Context, + common *api.CommonOpts, + consensusVersion spec.DataVersion, + bid any, +) error { + 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) + } + } + + endpoint := "/eth/v1/beacon/execution_payload_bids" + + headers := make(map[string]string) + headers["Eth-Consensus-Version"] = strings.ToLower(consensusVersion.String()) + + if _, err := s.post(ctx, endpoint, "", common, bytes.NewBuffer(body), contentType, headers); err != nil { + return errors.Join(errors.New("failed to submit execution payload bid"), err) + } + + return nil +} diff --git a/http/submitexecutionpayloadbid_test.go b/http/submitexecutionpayloadbid_test.go new file mode 100644 index 000000000..4674a7844 --- /dev/null +++ b/http/submitexecutionpayloadbid_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_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadBid(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SubmitExecutionPayloadBidOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NilBid", + opts: &api.SubmitExecutionPayloadBidOpts{}, + err: "no bid supplied", + }, + { + name: "UnsupportedVersion", + opts: &api.SubmitExecutionPayloadBidOpts{ + SignedExecutionPayloadBid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionPhase0, + }, + }, + err: "unsupported bid version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := service.(client.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, test.opts) + require.ErrorContains(t, err, test.err) + }) + } + + // A structurally-valid Gloas bid (with a garbage signature) passes + // client-side validation and exercises the marshal + POST path that the + // other cases never reach. The beacon node rejects it, so we tolerate any + // returned error rather than asserting a node-specific message. + t.Run("ReachesServer", func(t *testing.T) { + err := service.(client.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, &api.SubmitExecutionPayloadBidOpts{ + SignedExecutionPayloadBid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{}, + }, + }, + }) + require.Error(t, err) + }) +} diff --git a/http/submitexecutionpayloadenvelope.go b/http/submitexecutionpayloadenvelope.go new file mode 100644 index 000000000..e92cc8311 --- /dev/null +++ b/http/submitexecutionpayloadenvelope.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 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + apiv2 "github.com/attestantio/go-eth2-client/api/v2" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope +// using the stateless request form: a SignedExecutionPayloadEnvelopeContents +// body (signed envelope plus blobs and KZG proofs) with the +// Eth-Blob-Data-Included header set to true. The stateful form (blob data not +// included) only works when the beacon node cached the full envelope from its +// own block production, so builders publishing externally-built payloads must +// use the stateless form. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if opts.SignedExecutionPayloadEnvelope == nil { + return errors.Join(errors.New("no envelope supplied"), client.ErrInvalidOptions) + } + + versioned := opts.SignedExecutionPayloadEnvelope + + var contents any + + switch versioned.Version { + case spec.DataVersionGloas: + 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), + client.ErrInvalidOptions, + ) + } + + body, contentType, err := s.submitExecutionPayloadEnvelopeData(ctx, contents) + if err != nil { + return err + } + + return s.postExecutionPayloadEnvelope(ctx, &opts.Common, versioned.Version, + opts.BroadcastValidation, body, contentType) +} + +// postExecutionPayloadEnvelope performs the POST, setting the consensus +// version and stateless-form headers. +func (s *Service) postExecutionPayloadEnvelope(ctx context.Context, + common *api.CommonOpts, + consensusVersion spec.DataVersion, + broadcastValidation *apiv2.BroadcastValidation, + body []byte, + contentType ContentType, +) error { + endpoint := "/eth/v1/beacon/execution_payload_envelopes" + + query := "" + if broadcastValidation != nil { + query = "broadcast_validation=" + broadcastValidation.String() + } + + headers := make(map[string]string) + headers["Eth-Consensus-Version"] = strings.ToLower(consensusVersion.String()) + // Always the stateless form: header "true" selects the Contents body + // schema (signed envelope with its blobs and KZG proofs). Strict consensus + // clients reject the request when the header is missing. + headers["Eth-Blob-Data-Included"] = "true" + + if _, err := s.post(ctx, endpoint, query, common, bytes.NewBuffer(body), contentType, headers); err != nil { + return errors.Join(errors.New("failed to submit execution payload envelope"), err) + } + + return nil +} + +// 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 000000000..3924022b7 --- /dev/null +++ b/http/submitexecutionpayloadenvelope_test.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 http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadEnvelope(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SubmitExecutionPayloadEnvelopeOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NilEnvelope", + opts: &api.SubmitExecutionPayloadEnvelopeOpts{}, + err: "no envelope supplied", + }, + { + name: "UnsupportedVersion", + opts: &api.SubmitExecutionPayloadEnvelopeOpts{ + SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionPhase0, + }, + }, + err: "unsupported envelope version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := service.(client.ExecutionPayloadEnvelopeSubmitter).SubmitExecutionPayloadEnvelope(ctx, test.opts) + require.ErrorContains(t, err, test.err) + }) + } + + // A structurally-valid Gloas envelope (with a garbage signature) passes + // client-side validation and exercises the SSZ marshal + POST path (with + // the Eth-Blob-Data-Included header) that the other cases never reach. The + // beacon node rejects it, so we tolerate any returned error rather than + // asserting a node-specific message. + t.Run("ReachesServer", func(t *testing.T) { + err := service.(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.Error(t, err) + }) +} diff --git a/mock/service.go b/mock/service.go index c48ae855f..d0324696d 100644 --- a/mock/service.go +++ b/mock/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2025 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -50,40 +50,41 @@ type Service struct { SyncDistance phase0.Slot // Functions that can be provided to mock specific responses from this client. - AggregateAttestationFunc func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) - AttesterDutiesFunc func(context.Context, *api.AttesterDutiesOpts) (*api.Response[[]*apiv1.AttesterDuty], error) - AttestationDataFunc func(context.Context, *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) - AttestationRewardsFunc func(context.Context, *api.AttestationRewardsOpts) (*api.Response[*apiv1.AttestationRewards], error) - BeaconBlockHeaderFunc func(context.Context, *api.BeaconBlockHeaderOpts) (*api.Response[*apiv1.BeaconBlockHeader], error) - BeaconBlockRootFunc func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) - BeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*spec.VersionedBeaconState], error) - BeaconStateRandaoFunc func(context.Context, *api.BeaconStateRandaoOpts) (*api.Response[*phase0.Root], error) - BeaconStateRootFunc func(context.Context, *api.BeaconStateRootOpts) (*api.Response[*phase0.Root], error) - BlockRewardsFunc func(context.Context, *api.BlockRewardsOpts) (*api.Response[*apiv1.BlockRewards], error) - DepositContractFunc func(context.Context, *api.DepositContractOpts) (*api.Response[*apiv1.DepositContract], error) - EventsFunc func(context.Context, *api.EventsOpts) error - FinalityFunc func(context.Context, *api.FinalityOpts) (*api.Response[*apiv1.Finality], error) - ForkChoiceFunc func(context.Context, *api.ForkChoiceOpts) (*api.Response[*apiv1.ForkChoice], error) - ForkFunc func(context.Context, *api.ForkOpts) (*api.Response[*phase0.Fork], error) - ForkScheduleFunc func(context.Context, *api.ForkScheduleOpts) (*api.Response[[]*phase0.Fork], error) - GenesisFunc func(context.Context, *api.GenesisOpts) (*api.Response[*apiv1.Genesis], error) - NodePeersFunc func(context.Context, *api.NodePeersOpts) (*api.Response[[]*apiv1.Peer], error) - NodeSyncingFunc func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) - NodeVersionFunc func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error) - PendingDepositsFunc func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) - PendingConsolidationsFunc func(context.Context, *api.PendingConsolidationsOpts) (*api.Response[[]*electra.PendingConsolidation], error) - PendingPartialWithdrawalsFunc func(context.Context, *api.PendingPartialWithdrawalsOpts) (*api.Response[[]*electra.PendingPartialWithdrawal], error) - ProposalFunc func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) - ProposerDutiesFunc func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) - SignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*spec.VersionedSignedBeaconBlock], error) - SpecFunc func(context.Context, *api.SpecOpts) (*api.Response[map[string]any], error) - SyncCommitteeContributionFunc func(context.Context, *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error) - SyncCommitteeDutiesFunc func(context.Context, *api.SyncCommitteeDutiesOpts) (*api.Response[[]*apiv1.SyncCommitteeDuty], error) - SyncCommitteeRewardsFunc func(context.Context, *api.SyncCommitteeRewardsOpts) (*api.Response[[]*apiv1.SyncCommitteeReward], error) - ValidatorBalancesFunc func(context.Context, *api.ValidatorBalancesOpts) (*api.Response[map[phase0.ValidatorIndex]phase0.Gwei], error) - ValidatorLivenessFunc func(context.Context, *api.ValidatorLivenessOpts) (*api.Response[[]*apiv1.ValidatorLiveness], error) - ValidatorsFunc func(context.Context, *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*apiv1.Validator], error) - VoluntaryExitPoolFunc func(context.Context, *api.VoluntaryExitPoolOpts) (*api.Response[[]*phase0.SignedVoluntaryExit], error) + AggregateAttestationFunc func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) + AttesterDutiesFunc func(context.Context, *api.AttesterDutiesOpts) (*api.Response[[]*apiv1.AttesterDuty], error) + AttestationDataFunc func(context.Context, *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) + AttestationRewardsFunc func(context.Context, *api.AttestationRewardsOpts) (*api.Response[*apiv1.AttestationRewards], error) + BeaconBlockHeaderFunc func(context.Context, *api.BeaconBlockHeaderOpts) (*api.Response[*apiv1.BeaconBlockHeader], error) + BeaconBlockRootFunc func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) + BeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*spec.VersionedBeaconState], error) + BeaconStateRandaoFunc func(context.Context, *api.BeaconStateRandaoOpts) (*api.Response[*phase0.Root], error) + BeaconStateRootFunc func(context.Context, *api.BeaconStateRootOpts) (*api.Response[*phase0.Root], error) + BlockRewardsFunc func(context.Context, *api.BlockRewardsOpts) (*api.Response[*apiv1.BlockRewards], error) + DepositContractFunc func(context.Context, *api.DepositContractOpts) (*api.Response[*apiv1.DepositContract], error) + EventsFunc func(context.Context, *api.EventsOpts) error + FinalityFunc func(context.Context, *api.FinalityOpts) (*api.Response[*apiv1.Finality], error) + ForkChoiceFunc func(context.Context, *api.ForkChoiceOpts) (*api.Response[*apiv1.ForkChoice], error) + ForkFunc func(context.Context, *api.ForkOpts) (*api.Response[*phase0.Fork], error) + ForkScheduleFunc func(context.Context, *api.ForkScheduleOpts) (*api.Response[[]*phase0.Fork], error) + GenesisFunc func(context.Context, *api.GenesisOpts) (*api.Response[*apiv1.Genesis], error) + NodePeersFunc func(context.Context, *api.NodePeersOpts) (*api.Response[[]*apiv1.Peer], error) + NodeSyncingFunc func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) + NodeVersionFunc func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error) + PendingDepositsFunc func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) + PendingConsolidationsFunc func(context.Context, *api.PendingConsolidationsOpts) (*api.Response[[]*electra.PendingConsolidation], error) + PendingPartialWithdrawalsFunc func(context.Context, *api.PendingPartialWithdrawalsOpts) (*api.Response[[]*electra.PendingPartialWithdrawal], error) + ProposalFunc func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) + ProposerDutiesFunc func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) + SignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*spec.VersionedSignedBeaconBlock], error) + SignedExecutionPayloadEnvelopeFunc func(context.Context, *api.SignedExecutionPayloadEnvelopeOpts) (*api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], error) + SpecFunc func(context.Context, *api.SpecOpts) (*api.Response[map[string]any], error) + SyncCommitteeContributionFunc func(context.Context, *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error) + SyncCommitteeDutiesFunc func(context.Context, *api.SyncCommitteeDutiesOpts) (*api.Response[[]*apiv1.SyncCommitteeDuty], error) + SyncCommitteeRewardsFunc func(context.Context, *api.SyncCommitteeRewardsOpts) (*api.Response[[]*apiv1.SyncCommitteeReward], error) + ValidatorBalancesFunc func(context.Context, *api.ValidatorBalancesOpts) (*api.Response[map[phase0.ValidatorIndex]phase0.Gwei], error) + ValidatorLivenessFunc func(context.Context, *api.ValidatorLivenessOpts) (*api.Response[[]*apiv1.ValidatorLiveness], error) + ValidatorsFunc func(context.Context, *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*apiv1.Validator], error) + VoluntaryExitPoolFunc func(context.Context, *api.VoluntaryExitPoolOpts) (*api.Response[[]*phase0.SignedVoluntaryExit], error) } // log is a service-wide logger. diff --git a/mock/signedexecutionpayloadenvelope.go b/mock/signedexecutionpayloadenvelope.go new file mode 100644 index 000000000..21b7080fc --- /dev/null +++ b/mock/signedexecutionpayloadenvelope.go @@ -0,0 +1,59 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + if s.SignedExecutionPayloadEnvelopeFunc != nil { + return s.SignedExecutionPayloadEnvelopeFunc(ctx, opts) + } + + return &api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]{ + Data: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{ + Transactions: []bellatrix.Transaction{}, + Withdrawals: []*capella.Withdrawal{}, + }, + ExecutionRequests: &gloas.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + BuilderDeposits: []*gloas.BuilderDepositRequest{}, + BuilderExits: []*gloas.BuilderExitRequest{}, + }, + }, + }, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/submitexecutionpayloadbid.go b/mock/submitexecutionpayloadbid.go new file mode 100644 index 000000000..6828aa75b --- /dev/null +++ b/mock/submitexecutionpayloadbid.go @@ -0,0 +1,25 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (*Service) SubmitExecutionPayloadBid(_ context.Context, _ *api.SubmitExecutionPayloadBidOpts) error { + return nil +} diff --git a/mock/submitexecutionpayloadenvelope.go b/mock/submitexecutionpayloadenvelope.go new file mode 100644 index 000000000..9897ca3dd --- /dev/null +++ b/mock/submitexecutionpayloadenvelope.go @@ -0,0 +1,25 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (*Service) SubmitExecutionPayloadEnvelope(_ context.Context, _ *api.SubmitExecutionPayloadEnvelopeOpts) error { + return nil +} diff --git a/multi/signedexecutionpayloadenvelope.go b/multi/signedexecutionpayloadenvelope.go new file mode 100644 index 000000000..e5ce4b8d6 --- /dev/null +++ b/multi/signedexecutionpayloadenvelope.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + block, err := client.(consensusclient.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return block, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/submitexecutionpayloadbid.go b/multi/submitexecutionpayloadbid.go new file mode 100644 index 000000000..651ec77e6 --- /dev/null +++ b/multi/submitexecutionpayloadbid.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (s *Service) SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/multi/submitexecutionpayloadenvelope.go b/multi/submitexecutionpayloadenvelope.go new file mode 100644 index 000000000..f3f4f8dfd --- /dev/null +++ b/multi/submitexecutionpayloadenvelope.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.ExecutionPayloadEnvelopeSubmitter).SubmitExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/service.go b/service.go index 92bf8d330..ba9838883 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 @@ -685,3 +685,36 @@ type NodeClientProvider interface { // NodeClient provides the client for the node. NodeClient(ctx context.Context) (*api.Response[string], error) } + +// ExecutionPayloadProvider is the interface for providing execution payloads. +type ExecutionPayloadProvider interface { + // SignedExecutionPayloadEnvelope fetches a signed execution payload + // envelope given a block ID. Returns a versioned wrapper so callers can + // branch on Version regardless of which fork's envelope is populated. + SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, + ) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, + ) +} + +// ExecutionPayloadEnvelopeSubmitter is the interface for submitting execution +// payload envelopes. +type ExecutionPayloadEnvelopeSubmitter interface { + // SubmitExecutionPayloadEnvelope submits a signed execution payload + // envelope (with its blobs and KZG proofs) for broadcast. + SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, + ) error +} + +// ExecutionPayloadBidSubmitter is the interface for submitting execution +// payload bids. +type ExecutionPayloadBidSubmitter interface { + // SubmitExecutionPayloadBid submits a signed execution payload bid for + // gossip broadcast. + SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, + ) error +} diff --git a/testclients/erroring.go b/testclients/erroring.go index 5d33b82b5..f84e71971 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 @@ -1096,3 +1096,50 @@ 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) +} + +// 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) +} diff --git a/testclients/sleepy.go b/testclients/sleepy.go index e37ff0efa..1d0afdbd7 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 @@ -832,3 +832,44 @@ 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) +} + +// 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) +} From 8ce030d2979725126b769e1aa0ce48a54ba96591 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Sun, 26 Jul 2026 21:40:12 +0100 Subject: [PATCH 05/37] fix two JSON-path panics in spec/gloas ExecutionPayload codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent, JSON-path-only panics in the hand-written spec/gloas/executionpayload_json.go (the SSZ path is unaffected): - MarshalJSON dereferenced a nil BaseFeePerGas (*uint256.Int) through an unguarded .Dec(); a zero-value or partially-populated ExecutionPayload (e.g. the shape mock/signedexecutionpayloadenvelope.go builds) panicked. Now guarded with `if baseFeePerGas == nil { baseFeePerGas = new(uint256.Int) }`, mirroring the nil guard the generated SSZ marshaler already applies. - UnmarshalJSON computed make([]byte, (len(elem)-4)/2) per transaction element; a 1- or 2-byte raw JSON element slipped past the empty-element guard and underflowed to make([]byte, -1) (makeslice panic — a remote DoS from malformed beacon-node JSON). The guard is now `len(elem) < 4`, a strict superset of the prior len==0 and "" checks (no previously-valid encoding is newly rejected), keeping the explicit "0x" empty check. Key decisions: - Kept the marshal fix as an explicit *uint256.Int nil-guard (not a "0" string default) to mirror the SSZ sibling one-for-one, per the fix intent. - Widened the unmarshal guard's error from "missing" to "missing or malformed": the len<4 branch now also catches present-but-truncated tokens, not only absent ones. - Scope is the gloas copy only. The identical unguarded pattern in the spec/deneb (and other-fork) siblings is deliberately left untouched — a library-wide sweep is tracked as separate follow-up work. Test: new spec/gloas/executionpayload_test.go (package gloas_test, table-driven, testify/require) marshals a zero-value payload (nil BaseFeePerGas) and unmarshals 1- and 2-byte raw transaction elements; both reproduced as RED panics before the guards, GREEN after. Files changed: - modified: spec/gloas/executionpayload_json.go - new: spec/gloas/executionpayload_test.go Verified: go build ./...; gosilent test ./... (2204); go generate ./spec/gloas byte-identical (no SSZ drift); custom-gcl (attgo current-year plugin) 0 issues; -race clean on the new tests. Notes for next iteration: - A separate, pre-existing bug was found in the same UnmarshalJSON loop and captured as follow-up work: the MaxTransactionsPerPayload / MaxBytesPerTransaction bounds checks return errors.Wrap(err, ...) where err is nil, so pkg/errors.Wrap(nil, ...) returns nil and the checks fail open (oversized input silently accepted). Fail-open, not a panic/DoS; inherited from spec/deneb; needs a library-wide fix. --- spec/gloas/executionpayload_json.go | 18 ++++-- spec/gloas/executionpayload_test.go | 94 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 spec/gloas/executionpayload_test.go diff --git a/spec/gloas/executionpayload_json.go b/spec/gloas/executionpayload_json.go index 1a66db207..884c180ee 100644 --- a/spec/gloas/executionpayload_json.go +++ b/spec/gloas/executionpayload_json.go @@ -68,6 +68,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, @@ -80,7 +87,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, @@ -239,10 +246,13 @@ func (e *ExecutionPayload) UnmarshalJSON(input []byte) error { e.Transactions = make([]bellatrix.Transaction, len(transactions)) for i := range transactions { - if len(transactions[i]) == 0 || - bytes.Equal(transactions[i], []byte{'"', '"'}) || + // A transaction is a JSON-quoted, 0x-prefixed hex string; the shortest + // well-formed value is "0x" (4 bytes). Reject anything shorter — which + // also covers empty and "" — so the (len-4)/2 make() below cannot + // underflow to a negative length and panic. + if len(transactions[i]) < 4 || bytes.Equal(transactions[i], []byte{'"', '0', 'x', '"'}) { - return fmt.Errorf("transaction %d: missing", i) + 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 000000000..08a13a8ab --- /dev/null +++ b/spec/gloas/executionpayload_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 gloas_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "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) + }) + } +} + +// TestExecutionPayloadUnmarshalJSON verifies that unmarshaling does not panic on +// a transaction element too short to carry the "0x" framing, whose decoded +// length (len-4)/2 would otherwise underflow to a negative make() size. +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", + }, + } + + 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) + }) + require.EqualError(t, err, test.err) + }) + } +} From f214eed65fd85ba0fae6e101d5b059792eb9f69e Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Sun, 26 Jul 2026 23:41:49 +0100 Subject: [PATCH 06/37] gloas-enable the mechanical versioned HTTP endpoints (beacon state, aggregate, attestation submit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive Gloas (Glamsterdam devnet-7) enablement for the mechanical half of the existing versioned HTTP endpoints: add a spec.DataVersionGloas arm to each fork-version switch, decoding into / encoding from the spec/gloas types already on the branch. Every arm is a structural mirror of its electra/fulu sibling; no existing behaviour changes. What landed: - http/beaconstate.go: gloas arms in beaconStateFromSSZ and beaconStateFromJSON (decode into gloas.BeaconState). Fixes "unhandled state version gloas" on the BeaconState endpoint and its cascade into the Validators endpoint (which decodes the beacon state). - http/aggregateattestation.go: gloas arm in decodeAggregateAttestation. A Gloas node reports Eth-Consensus-Version: gloas for the aggregate endpoint, which previously fell through to "unknown consensus version". Decodes into gloas.Attestation. - http/submitattestations.go: gloas arm in createUnversionedAttestations (attestations[i].Gloas.ToSingleAttestation), for callers submitting gloas-versioned attestations. - http/submitaggregateattestations.go: gloas arm in createUnversionedAggregates (appends the electra-typed .Gloas field). - 3 internal unit tests (package http): the gloas aggregate-decode arm, and the two submit-encode helpers (createUnversionedAttestations / createUnversionedAggregates had no prior unit test at all). Key decisions: - attestationpool.go is deliberately NOT given a gloas arm. verifyAttestationPool switches on datum.Version, but spec/versionedattestation_json.go's UnmarshalJSON identifies the attestation variant only by the presence of committee_bits and always labels it Electra, never Gloas. Gloas attestations are wire-identical to Electra, so they already decode and verify correctly as Electra; a gloas arm there is unreachable dead code and would need a duplicate verifyGloasAttestation helper (datum.Gloas is *gloas.Attestation, not *electra.Attestation). - Scope is the mechanical endpoints only. The ePBS block/proposal endpoints (signedbeaconblock, proposal, blindedproposal, submitproposal, submitblindedproposal, submitbeaconblock, submitblindedbeaconblock) are NOT mechanical: under ePBS the block carries a SignedExecutionPayloadBid rather than an inline payload, api/v1/gloas ships no proposal types yet, and "blinded proposal" needs a design decision. They are deferred as separate follow-up work. - Copyright headers on the 4 touched source files bumped to a 2020 - 2026 range; the modified test file to 2025 - 2026 (attgo current-year lint gate). New test files carry 2026-only headers. Verification: - go build ./...; gosilent test ./... (2204); custom-gcl run ./http/... 0 issues; go generate ./spec/gloas byte-identical (no SSZ drift). - Against a live Gloas devnet (Lighthouse, minimal preset, head block version gloas, service built with WithCustomSpecSupport(true)): TestBeaconState/Head (SSZ) and TestValidators (Head/Finalized/Justified/ExitedValidators) go green, the 3 new internal tests pass, and the pre-existing ePBS execution-payload envelope/bid endpoint tests and SSE event-handler tests stay green. Files changed: - modified: http/beaconstate.go, http/aggregateattestation.go, http/submitattestations.go, http/submitaggregateattestations.go, http/aggregateattestation_internal_test.go - new: http/submitattestations_internal_test.go, http/submitaggregateattestations_internal_test.go Blockers / notes for next iteration: - gloas.BeaconState JSON codec bug: execution_payload_availability (a Bitvector[SLOTS_PER_HISTORICAL_ROOT]) is marshalled as a decimal-string array and unmarshalled as base64, instead of a single 0x hex string the way justification_bits is handled in the same file. The JSON beaconstate arm added here is correct but delegates to this buggy hand-written codec, so TestBeaconState/Head_(json) stays red against a Gloas node. Fixing one field may expose others (e.g. PTCWindow uses the same shape) — needs a field-by-field JSON audit of gloas.BeaconState. The SSZ path (the endpoint's preferred encoding) is unaffected and green. Tracked as separate follow-up work. - TestValidators/ManyValidatorIndices fails against the devnet only because it expects 10000 validators (mainnet scale) and the devnet has 256; unrelated to gloas or this change. - The ePBS block/proposal endpoint arms remain to be done (separate follow-up). --- http/aggregateattestation.go | 13 +++- http/aggregateattestation_internal_test.go | 21 ++++++- http/beaconstate.go | 16 ++++- http/submitaggregateattestations.go | 4 +- ...bmitaggregateattestations_internal_test.go | 40 ++++++++++++ http/submitattestations.go | 11 +++- http/submitattestations_internal_test.go | 63 +++++++++++++++++++ 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 http/submitaggregateattestations_internal_test.go create mode 100644 http/submitattestations_internal_test.go diff --git a/http/aggregateattestation.go b/http/aggregateattestation.go index a1e435076..fa4bdbfe0 100644 --- a/http/aggregateattestation.go +++ b/http/aggregateattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -23,6 +23,7 @@ import ( "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -172,6 +173,16 @@ func decodeAggregateAttestation(httpResponse *httpResponse) (*spec.VersionedAtte return &spec.VersionedAttestation{}, nil, decodeErr } + return data, metadata, nil + case spec.DataVersionGloas: + gloasData, gloasMetadata, decodeErr := decodeJSONResponse(bytes.NewReader(httpResponse.body), &gloas.Attestation{}) + metadata = gloasMetadata + data.Gloas = gloasData + + if decodeErr != nil { + return &spec.VersionedAttestation{}, nil, decodeErr + } + return data, metadata, nil default: return &spec.VersionedAttestation{}, nil, errors.New("unknown consensus version") diff --git a/http/aggregateattestation_internal_test.go b/http/aggregateattestation_internal_test.go index 2fac89ae8..4592738a6 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,6 +17,7 @@ import ( "testing" "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) @@ -56,4 +57,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/beaconstate.go b/http/beaconstate.go index 6e9e43661..35544aef3 100644 --- a/http/beaconstate.go +++ b/http/beaconstate.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -28,6 +28,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/fulu" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" dynssz "github.com/pk910/dynamic-ssz" ) @@ -167,6 +168,17 @@ func (s *Service) beaconStateFromSSZ(ctx context.Context, res *httpResponse) (*a if err != nil { return nil, errors.Join(errors.New("failed to decode fulu beacon state"), err) } + case spec.DataVersionGloas: + response.Data.Gloas = &gloas.BeaconState{} + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + } else { + err = response.Data.Gloas.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode gloas beacon state"), err) + } default: return nil, fmt.Errorf("unhandled state version %s", res.consensusVersion) } @@ -198,6 +210,8 @@ func (*Service) beaconStateFromJSON(res *httpResponse) (*api.Response[*spec.Vers response.Data.Electra, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &electra.BeaconState{}) case spec.DataVersionFulu: response.Data.Fulu, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &fulu.BeaconState{}) + case spec.DataVersionGloas: + response.Data.Gloas, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &gloas.BeaconState{}) default: err = fmt.Errorf("unsupported version %s", res.consensusVersion) } diff --git a/http/submitaggregateattestations.go b/http/submitaggregateattestations.go index d17368fec..d46295eb3 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 @@ -105,6 +105,8 @@ func createUnversionedAggregates(aggregateAndProofs []*spec.VersionedSignedAggre unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Electra) case spec.DataVersionFulu: unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Fulu) + case spec.DataVersionGloas: + 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 000000000..24b3ac63f --- /dev/null +++ b/http/submitaggregateattestations_internal_test.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 http + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/stretchr/testify/require" +) + +// TestCreateUnversionedAggregatesGloas verifies that a Gloas-versioned aggregate and proof is +// unwrapped for submission. Under Gloas the aggregate and proof wire format is unchanged from +// Electra (VersionedSignedAggregateAndProof.Gloas is *electra.SignedAggregateAndProof), so the +// arm forwards the Electra-typed value; without it the switch hits its default and errors. +func TestCreateUnversionedAggregatesGloas(t *testing.T) { + aggregate := &electra.SignedAggregateAndProof{ + Message: &electra.AggregateAndProof{AggregatorIndex: 42}, + } + aggregateAndProofs := []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionGloas, Gloas: aggregate}, + } + + unversioned, err := createUnversionedAggregates(aggregateAndProofs) + require.NoError(t, err) + require.Len(t, unversioned, 1) + require.Equal(t, aggregate, unversioned[0]) +} diff --git a/http/submitattestations.go b/http/submitattestations.go index ee8d806bf..8c767d7c3 100644 --- a/http/submitattestations.go +++ b/http/submitattestations.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -118,6 +118,15 @@ func (s *Service) createUnversionedAttestations(attestations []*spec.VersionedAt continue } + unversionedAttestations = append(unversionedAttestations, singleAttestation) + case spec.DataVersionGloas: + 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 000000000..915348ec6 --- /dev/null +++ b/http/submitattestations_internal_test.go @@ -0,0 +1,63 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// 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) +} From 50beacf22ca4962f64b6263d0be347b66a2e330b Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 10:42:20 +0200 Subject: [PATCH 07/37] validate decoded hex length before copy() in spec/gloas JSON unmarshalers The hand-written spec/gloas JSON unmarshalers hex-decoded a value and copied it straight into a fixed-size array with no length check. hex.DecodeString succeeds for any even-length input, so a short value silently zero-padded the array's tail and a long one was silently truncated, while UnmarshalJSON still returned nil. The caller received a corrupted root, hash, address or signature with no error surfaced -- fail-open silent data corruption reachable from malformed or malicious beacon-node JSON, and worse than a panic because fork-choice and signature-domain logic keyed on the value then produces a wrong answer rather than crashing. 20 unguarded sites across 13 files now reject a wrong-length value before the copy, each with a descriptive error naming the field, matching the guard idiom already used by the package's other unmarshalers (attestation.go, builderdepositrequest_json.go, aggregateandproof.go) and by the sibling fork packages: if len(x) != phase0.RootLength { return errors.New("incorrect length for ") } Sites, by file (length constant chosen from the field's real Go type): - beaconblockbody_json.go: RANDAOReveal (SignatureLength), Graffiti (GraffitiLength) - beaconstate_json.go: LatestBlockHash (Hash32Length) - executionpayloadbid_json.go: ParentBlockHash, BlockHash (Hash32Length); ParentBlockRoot, PrevRandao, ExecutionRequestsRoot (RootLength); FeeRecipient (bellatrix.FeeRecipientLength) - executionpayloadenvelope_json.go: BeaconBlockRoot, ParentBeaconBlockRoot (RootLength) - payloadattestationdata_json.go: BeaconBlockRoot (RootLength) - proposerpreferences_json.go: FeeRecipient (bellatrix.FeeRecipientLength) - signature fields (SignatureLength): signedbeaconblock_json.go, signedexecutionpayloadbid_json.go, signedexecutionpayloadenvelope_json.go, signedproposerpreferences_json.go, payloadattestation_json.go, payloadattestationmessage_json.go, indexedpayloadattestation_json.go Key decisions: - phase0.RootLength vs phase0.Hash32Length are both 32, but the constant records the field's semantic type (Merkle root vs execution block hash) so a future type change stays type-correct rather than accidentally correct. - The YAML layer needed no change: every spec/gloas *_yaml.go UnmarshalYAML routes through UnmarshalJSON, except builderdepositrequest_yaml.go which calls the already-guarded shared unpack(). No hex-decode-into-array code exists in the YAML layer, so the JSON fix covers YAML transitively. Verified by reading all 18 YAML unmarshalers, not assumed. - The duplication across the 20 guards is deliberate. A decodeFixedBytes helper already exists in api/v1/executionpayloadevent.go and consolidating onto it is tracked as separate follow-up work; introducing a cross-package abstraction in the same change as a data-corruption fix would make the fix harder to review and to backport. This change stays mechanical and uniform. - Adding six guards pushed ExecutionPayloadBid.UnmarshalJSON to gocyclo complexity 34 against the default threshold of 30. .golangci.yml carries no gocyclo settings block, but the repo's actual answer to this is the inline //nolint:gocyclo directive -- 33 of them, two already in this package (beaconstate_json.go, executionpayload_json.go) and one on the closest structural sibling, spec/deneb/executionpayloadheader_json.go's UnmarshalJSON. This change follows that convention rather than restructuring the function, which keeps the patch purely additive: 70 insertions, 0 deletions, every added line a guard, an import, or the directive. No existing line moves, so the diff a reviewer must audit contains only new checks. - spec/gloas/builder.go was left alone: its execution-address length check exists and fails closed, but names the wrong field ("fee recipient") and has an unreachable duplicate after the copy. Different bug class, captured as separate follow-up work rather than folded in here. Test: new spec/gloas/jsonlength_test.go (package gloas_test, table-driven, testify/require). TestJSONFixedLengthGuards has one case per newly-guarded site (23 cases), with both too-short and too-long variants for a representative root, address and signature. Every case was confirmed RED before its guard landed -- and revealingly so: without the guard the unmarshaler does not merely return nil, it sails past the corrupt field entirely and errors later on an unrelated one. TestJSONFixedLengthRoundTrip is the counterpart, asserting nine types still marshal/unmarshal byte-identically so the guards cannot over-reject; it was mutation-checked (an off-by-one length constant makes it fail), so it is not vacuous. Files changed: - modified: spec/gloas/{beaconblockbody,beaconstate,executionpayloadbid, executionpayloadenvelope,indexedpayloadattestation,payloadattestation, payloadattestationdata,payloadattestationmessage,proposerpreferences, signedbeaconblock,signedexecutionpayloadbid,signedexecutionpayloadenvelope, signedproposerpreferences}_json.go - new: spec/gloas/jsonlength_test.go Verified: go build ./... clean; gosilent test ./... (2236) green; -race clean on spec/gloas; custom-gcl (attgo current-year plugin) 0 issues repo-wide; go generate ./spec/gloas byte-identical (no SSZ drift). A scripted sweep confirms all 30 copy()-into-fixed-array sites in the hand-written spec/gloas sources now have a preceding length check, 0 unguarded. An independent reviewer additionally mutation-tested the guards -- deleting any one of them fails exactly and only its own subtest -- and the round-trip test was mutation-checked the same way (an off-by-one length constant makes it fail), so neither test is vacuous. Blockers / notes for next iteration: - The consensus-spec ssz_static vectors gate could NOT be run, and not for a local reason: ethereum/consensus-spec-tests has no v1.7.0-alpha.12 release published at all. The newest release is v1.6.0-beta.0 (2025-09-24), which predates gloas and carries no tests/mainnet/gloas directory, so the target vectors are unobtainable rather than merely absent from this machine. Any plan step that gates on those vectors is blocked upstream until the spec release ships them. This change touches only the JSON codec, and go generate confirms no SSZ drift, so the gate has no bearing on it either way. - The same unguarded copy()-into-fixed-array pattern very likely persists in the other fork packages this code was modelled on; only spec/gloas was swept here. A library-wide audit is worth scoping. - A security review of this change confirmed two residual fail-open paths in spec/gloas that are outside this bug class and were deliberately left alone, both already captured as separate work: * executionpayload_json.go's transaction bounds checks return errors.Wrap(err, ...) where err is provably nil, so UnmarshalJSON returns success. Reproduced: a ~3 MB body with 1,048,577 empty transaction entries parses "successfully" with a zeroed tail (Transactions, Withdrawals, BlobGasUsed, ExcessBlobGas, BlockAccessList, SlotNumber), contradicting the values the document declared. Network-reachable via http/signedexecutionpayloadenvelope.go. * fixed-width bitvectors (PayloadAttestation.AggregationBits, BeaconState.JustificationBits, Attestation.CommitteeBits) are hex-decoded into slice-typed fields with no length check, so a wrong-length value is stored as-is and UnmarshalJSON returns nil. NOT mechanically fixable: two of the three carry dynssz-size tags (PTC_SIZE/8, MAX_COMMITTEES_PER_SLOT/8) whose byte width is resolved at runtime from the node's config, so a hardcoded guard would reject valid minimal-preset data -- the preset this branch is validated against. Only JustificationBits is preset-independent. - The premise that these guards should be consolidated onto the existing decodeFixedBytes helper needs revisiting before anyone acts on it: that helper is unexported in package api/v1 with two callers, both in its own defining file, and emits "incorrect length %d for %s" against the house form "incorrect length for " (138 uses under spec/ vs 58). A gloas-only adoption would add a third dialect rather than remove one; if wanted, it should be a single repo-wide sweep preserving the house string. --- spec/gloas/beaconblockbody_json.go | 6 + spec/gloas/beaconstate_json.go | 3 + spec/gloas/executionpayloadbid_json.go | 21 ++ spec/gloas/executionpayloadenvelope_json.go | 7 + spec/gloas/indexedpayloadattestation_json.go | 3 + spec/gloas/jsonlength_test.go | 344 ++++++++++++++++++ spec/gloas/payloadattestation_json.go | 4 + spec/gloas/payloadattestationdata_json.go | 3 + spec/gloas/payloadattestationmessage_json.go | 3 + spec/gloas/proposerpreferences_json.go | 4 + spec/gloas/signedbeaconblock_json.go | 4 + spec/gloas/signedexecutionpayloadbid_json.go | 4 + .../signedexecutionpayloadenvelope_json.go | 4 + spec/gloas/signedproposerpreferences_json.go | 4 + 14 files changed, 414 insertions(+) create mode 100644 spec/gloas/jsonlength_test.go diff --git a/spec/gloas/beaconblockbody_json.go b/spec/gloas/beaconblockbody_json.go index d779fe85d..be167e7dd 100644 --- a/spec/gloas/beaconblockbody_json.go +++ b/spec/gloas/beaconblockbody_json.go @@ -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/beaconstate_json.go b/spec/gloas/beaconstate_json.go index ffdf724fc..b7dde46ed 100644 --- a/spec/gloas/beaconstate_json.go +++ b/spec/gloas/beaconstate_json.go @@ -317,6 +317,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 { diff --git a/spec/gloas/executionpayloadbid_json.go b/spec/gloas/executionpayloadbid_json.go index 958452bca..d7c8b2dff 100644 --- a/spec/gloas/executionpayloadbid_json.go +++ b/spec/gloas/executionpayloadbid_json.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" @@ -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/executionpayloadenvelope_json.go b/spec/gloas/executionpayloadenvelope_json.go index cd53d5d64..a35f835f5 100644 --- a/spec/gloas/executionpayloadenvelope_json.go +++ b/spec/gloas/executionpayloadenvelope_json.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/attestantio/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/indexedpayloadattestation_json.go b/spec/gloas/indexedpayloadattestation_json.go index f4abd1b95..4afd977ce 100644 --- a/spec/gloas/indexedpayloadattestation_json.go +++ b/spec/gloas/indexedpayloadattestation_json.go @@ -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/jsonlength_test.go b/spec/gloas/jsonlength_test.go new file mode 100644 index 000000000..0a8ae000c --- /dev/null +++ b/spec/gloas/jsonlength_test.go @@ -0,0 +1,344 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// hexOfLen returns a 0x-prefixed hex string that decodes to exactly n bytes. +func hexOfLen(n int) string { + return "0x" + strings.Repeat("ab", n) +} + +// mustMarshal renders v as JSON, which the tests use to build a valid document +// for the types whose JSON is too large to spell out by hand. +func mustMarshal(t *testing.T, v any) string { + t.Helper() + + data, err := json.Marshal(v) + require.NoError(t, err) + + return string(data) +} + +// withHexField returns input with the hex value of the given JSON key replaced, +// making a single field wrong-length while the rest of the document stays valid. +// The key must appear exactly once, so the mutation is unambiguous. Input comes +// from mustMarshal, so the pattern can assume compact JSON and lowercase hex. +func withHexField(t *testing.T, input, key, value string) string { + t.Helper() + + re := regexp.MustCompile(`"` + key + `":"0x[0-9a-f]*"`) + require.Len(t, re.FindAllString(input, -1), 1, "key %q must appear exactly once", key) + + return re.ReplaceAllString(input, `"`+key+`":"`+value+`"`) +} + +// validExecutionPayloadEnvelope returns an envelope whose nested payload and +// execution requests satisfy the checks preceding the fields under test. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(0)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + } +} + +// validBeaconState returns a state populated just far enough for the +// unmarshaler to reach latest_block_hash: every field checked before it must be +// present and well-formed. Fields after it are left at their zero value, which +// is harmless because a wrong-length latest_block_hash short-circuits first. +func validBeaconState() *gloas.BeaconState { + // One pubkey per committee suffices: altair.SyncCommittee's unpack only + // requires a non-empty list, and gloas.BeaconState adds no size check. A + // spec-sized 512 would inflate this fixture from ~2.6 KB to ~106 KB. + pubkeys := make([]phase0.BLSPubKey, 1) + + return &gloas.BeaconState{ + Fork: &phase0.Fork{}, + LatestBlockHeader: &phase0.BeaconBlockHeader{}, + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + JustificationBits: bitfield.NewBitvector4(), + PreviousJustifiedCheckpoint: &phase0.Checkpoint{}, + CurrentJustifiedCheckpoint: &phase0.Checkpoint{}, + FinalizedCheckpoint: &phase0.Checkpoint{}, + CurrentSyncCommittee: &altair.SyncCommittee{Pubkeys: pubkeys}, + NextSyncCommittee: &altair.SyncCommittee{Pubkeys: pubkeys}, + } +} + +// validBeaconBlock returns a block whose body satisfies the nested checks that +// precede a signed block's own signature. +func validBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// TestJSONFixedLengthGuards verifies that the hand-written JSON unmarshalers +// reject a hex value whose decoded length does not match the fixed-size array it +// is copied into. hex.DecodeString accepts any even-length input, so without a +// length check a short value silently zero-pads the array's tail and a long one +// is silently truncated, with UnmarshalJSON still returning nil — handing the +// caller a corrupted root, hash, address or signature and surfacing no error. +func TestJSONFixedLengthGuards(t *testing.T) { + bidJSON := mustMarshal(t, &gloas.ExecutionPayloadBid{}) + envelopeJSON := mustMarshal(t, validExecutionPayloadEnvelope()) + blockJSON := mustMarshal(t, validBeaconBlock()) + stateJSON := mustMarshal(t, validBeaconState()) + dataJSON := mustMarshal(t, &gloas.PayloadAttestationData{}) + prefsJSON := mustMarshal(t, &gloas.ProposerPreferences{}) + + tests := []struct { + name string + target json.Unmarshaler + input string + err string + }{ + { + name: "PayloadAttestationDataBeaconBlockRootShort", + target: &gloas.PayloadAttestationData{}, + input: `{"beacon_block_root":"` + hexOfLen(31) + `","slot":"1","payload_present":true,"blob_data_available":true}`, + err: "incorrect length for beacon block root", + }, + { + name: "PayloadAttestationDataBeaconBlockRootLong", + target: &gloas.PayloadAttestationData{}, + input: `{"beacon_block_root":"` + hexOfLen(33) + `","slot":"1","payload_present":true,"blob_data_available":true}`, + err: "incorrect length for beacon block root", + }, + { + name: "PayloadAttestationMessageSignatureShort", + target: &gloas.PayloadAttestationMessage{}, + input: `{"validator_index":"1","data":` + dataJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "PayloadAttestationMessageSignatureLong", + target: &gloas.PayloadAttestationMessage{}, + input: `{"validator_index":"1","data":` + dataJSON + `,"signature":"` + hexOfLen(97) + `"}`, + err: "incorrect length for signature", + }, + { + name: "ProposerPreferencesFeeRecipientShort", + target: &gloas.ProposerPreferences{}, + input: withHexField(t, prefsJSON, "fee_recipient", hexOfLen(19)), + err: "incorrect length for fee recipient", + }, + { + name: "ProposerPreferencesFeeRecipientLong", + target: &gloas.ProposerPreferences{}, + input: withHexField(t, prefsJSON, "fee_recipient", hexOfLen(21)), + err: "incorrect length for fee recipient", + }, + { + name: "PayloadAttestationSignatureShort", + target: &gloas.PayloadAttestation{}, + input: `{"aggregation_bits":"` + hexOfLen(64) + `","data":` + dataJSON + + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "IndexedPayloadAttestationSignatureShort", + target: &gloas.IndexedPayloadAttestation{}, + input: `{"attesting_indices":["1"],"data":` + dataJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedProposerPreferencesSignatureShort", + target: &gloas.SignedProposerPreferences{}, + input: `{"message":` + prefsJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedExecutionPayloadBidSignatureShort", + target: &gloas.SignedExecutionPayloadBid{}, + input: `{"message":` + bidJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedExecutionPayloadEnvelopeSignatureShort", + target: &gloas.SignedExecutionPayloadEnvelope{}, + input: `{"message":` + envelopeJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedBeaconBlockSignatureShort", + target: &gloas.SignedBeaconBlock{}, + input: `{"message":` + blockJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "ExecutionPayloadEnvelopeBeaconBlockRootShort", + target: &gloas.ExecutionPayloadEnvelope{}, + input: withHexField(t, envelopeJSON, "beacon_block_root", hexOfLen(31)), + err: "incorrect length for beacon block root", + }, + { + name: "ExecutionPayloadEnvelopeParentBeaconBlockRootShort", + target: &gloas.ExecutionPayloadEnvelope{}, + input: withHexField(t, envelopeJSON, "parent_beacon_block_root", hexOfLen(31)), + err: "incorrect length for parent beacon block root", + }, + { + name: "ExecutionPayloadBidParentBlockHashShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "parent_block_hash", hexOfLen(31)), + err: "incorrect length for parent block hash", + }, + { + name: "ExecutionPayloadBidParentBlockRootShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "parent_block_root", hexOfLen(31)), + err: "incorrect length for parent block root", + }, + { + name: "ExecutionPayloadBidBlockHashShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "block_hash", hexOfLen(31)), + err: "incorrect length for block hash", + }, + { + name: "ExecutionPayloadBidPrevRandaoShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "prev_randao", hexOfLen(31)), + err: "incorrect length for prev randao", + }, + { + name: "ExecutionPayloadBidFeeRecipientShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "fee_recipient", hexOfLen(19)), + err: "incorrect length for fee recipient", + }, + { + name: "ExecutionPayloadBidExecutionRequestsRootShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "execution_requests_root", hexOfLen(31)), + err: "incorrect length for execution requests root", + }, + { + // randao_reveal is the body's first check, so no other field is needed. + name: "BeaconBlockBodyRANDAORevealShort", + target: &gloas.BeaconBlockBody{}, + input: `{"randao_reveal":"` + hexOfLen(95) + `"}`, + err: "incorrect length for randao reveal", + }, + { + name: "BeaconBlockBodyGraffitiShort", + target: &gloas.BeaconBlockBody{}, + input: `{"randao_reveal":"` + hexOfLen(96) + `","graffiti":"` + hexOfLen(31) + `"}`, + err: "incorrect length for graffiti", + }, + { + name: "BeaconStateLatestBlockHashShort", + target: &gloas.BeaconState{}, + input: withHexField(t, stateJSON, "latest_block_hash", hexOfLen(31)), + err: "incorrect length for latest block hash", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := json.Unmarshal([]byte(test.input), test.target) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestJSONFixedLengthRoundTrip is the counterpart to TestJSONFixedLengthGuards: +// it verifies the guards reject only genuinely wrong-length values. A +// correctly-sized document must still unmarshal and re-marshal unchanged, which +// would fail if a guard used the wrong length constant or a one-sided +// comparison. +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{}, + }, + } + + 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_json.go b/spec/gloas/payloadattestation_json.go index d947d4c13..5771737f5 100644 --- a/spec/gloas/payloadattestation_json.go +++ b/spec/gloas/payloadattestation_json.go @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/attestantio/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/payloadattestationdata_json.go b/spec/gloas/payloadattestationdata_json.go index adcd0481c..b9d480bd1 100644 --- a/spec/gloas/payloadattestationdata_json.go +++ b/spec/gloas/payloadattestationdata_json.go @@ -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/payloadattestationmessage_json.go b/spec/gloas/payloadattestationmessage_json.go index 069560857..9d1dc6693 100644 --- a/spec/gloas/payloadattestationmessage_json.go +++ b/spec/gloas/payloadattestationmessage_json.go @@ -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/proposerpreferences_json.go b/spec/gloas/proposerpreferences_json.go index 175d7adea..03e3e0451 100644 --- a/spec/gloas/proposerpreferences_json.go +++ b/spec/gloas/proposerpreferences_json.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/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/signedbeaconblock_json.go b/spec/gloas/signedbeaconblock_json.go index dc2feb10e..671ca0ed7 100644 --- a/spec/gloas/signedbeaconblock_json.go +++ b/spec/gloas/signedbeaconblock_json.go @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/attestantio/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/signedexecutionpayloadbid_json.go b/spec/gloas/signedexecutionpayloadbid_json.go index 3b8cfbef1..60ac18041 100644 --- a/spec/gloas/signedexecutionpayloadbid_json.go +++ b/spec/gloas/signedexecutionpayloadbid_json.go @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/attestantio/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/signedexecutionpayloadenvelope_json.go b/spec/gloas/signedexecutionpayloadenvelope_json.go index 05809ed90..73af3365d 100644 --- a/spec/gloas/signedexecutionpayloadenvelope_json.go +++ b/spec/gloas/signedexecutionpayloadenvelope_json.go @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/attestantio/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/signedproposerpreferences_json.go b/spec/gloas/signedproposerpreferences_json.go index 1c6706749..adbf82233 100644 --- a/spec/gloas/signedproposerpreferences_json.go +++ b/spec/gloas/signedproposerpreferences_json.go @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/attestantio/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 From 3a612a9bf73c7a78da40f5c7f212b4f9809ab1ef Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 11:51:09 +0200 Subject: [PATCH 08/37] guard nil BaseFeePerGas in the spec/gloas ExecutionPayload YAML marshaler The hand-written spec/gloas/executionpayload_yaml.go MarshalYAML called e.BaseFeePerGas.Dec() unconditionally, where BaseFeePerGas is a nilable *uint256.Int. (*uint256.Int).Dec() calls IsZero(), which indexes z[0] immediately, so a nil receiver panics rather than returning a zero string. An earlier change fixed exactly this on the JSON path and the generated SSZ marshaler has always guarded it; only the hand-written YAML marshaler was missed, leaving the three encodings of one type disagreeing about whether a zero-value payload is marshalable. YAML is not a niche path here: String() marshals through it, so the panic is reachable from ExecutionPayload.String(), and transitively from ExecutionPayloadEnvelope.String() and spec.VersionedSignedExecutionPayloadEnvelope.String() -- the envelope YAML marshaler passes the payload to the encoder, which dispatches into the payload's own MarshalYAML. Merely logging a fetched or mocked envelope crashed the caller, and mock/signedexecutionpayloadenvelope.go builds precisely the crashing shape (a payload with only Transactions and Withdrawals set). The fix is the nil guard its JSON sibling already carries, applied verbatim. Key decisions: - The guard is a byte-for-byte mirror of the JSON sibling's: same comment text, same baseFeePerGas variable, same placement immediately after the extraData/blockAccessList prep. Shorter forms were considered and rejected. Mutating the receiver (e.BaseFeePerGas = new(uint256.Int)) saves two lines but has a marshaler write to its receiver, which races the moment one payload is logged and served concurrently. Substituting a literal "0" string avoids the allocation but breaks the symmetry this change exists to restore. cmp.Or is one line and stdlib, but appears nowhere in this repo and would itself become a new kind of drift between the two marshalers. - Substituting a zero value rather than erroring is deliberate, and matches what the JSON and generated SSZ marshalers of this type already do; making YAML alone fail closed would leave the diagnostic path stricter than the hashing and signing paths. Nothing is masked at a trust boundary either: both decoders guarantee a non-nil BaseFeePerGas -- codecs.RawJSON rejects a payload missing the required base_fee_per_gas key, and the SSZ decoder allocates unconditionally -- so a nil value can only originate from in-process construction, never from remote input. - The guard costs nothing measurable. A benchmark of the real MarshalYAML shows the nil and non-nil branches at an identical 486 allocs/op: the compiler proves new(uint256.Int) does not escape, since Dec() does not retain its receiver, so it is stack-allocated. - Scope stayed gloas-only. The identical unguarded pattern in spec/deneb is confirmed and captured as separate follow-up work rather than folded in (see below); widening a one-line fix into a cross-fork sweep would make both harder to review. - Criterion coverage for "the JSON and YAML pair must not drift" is met by two adjacent mirrored tests rather than one unified table, so the existing JSON test is left untouched and the diff stays additive. Sweep (the reason no other file changed): all 18 hand-written spec/gloas *_yaml.go MarshalYAML functions were audited for nilable-pointer dereferences, field types resolved via go doc rather than inferred. BaseFeePerGas is the only pointer field any of them dereferences. Every other deref is a value receiver on a fixed-size byte array (phase0.Root, phase0.Hash32, bellatrix.ExecutionAddress) or on a slice type (bitfield.Bitvector4), neither of which can be nil. Nilable pointers that are merely assigned into a YAML target struct are not a panic: goccy/go-yaml checks isInvalidValue before canEncodeByMarshaler, so a nil pointer is emitted as null and its MarshalYAML is never invoked. That last point was verified by reading the encoder, not assumed, and it is also what makes the transitive String() chain safe at every hop except the innermost one fixed here. Test: new TestExecutionPayloadMarshalYAML in spec/gloas/executionpayload_test.go (the direct sibling of the existing JSON test), plus spec/gloas/executionpayloadenvelope_test.go and spec/versionedsignedexecutionpayloadenvelope_test.go for the two transitive String() callers. All three were confirmed RED before the guard landed -- reverted to the pre-fix marshaler, each failed with a nil-pointer deref raised through the goccy encoder, then passed once restored -- so none is vacuous. The versioned wrapper's ZeroValue case pins a non-obvious fact worth recording: version.DataVersion's zero value is DataVersionUnknown = iota, not DataVersionPhase0, so a zero-value wrapper takes String()'s default arm and renders "unknown version" rather than the empty string that the switch's first case label suggests. Files changed: - modified: spec/gloas/executionpayload_yaml.go (nil guard + uint256 import; 9 insertions, 1 deletion, no existing line moved) - modified: spec/gloas/executionpayload_test.go (TestExecutionPayloadMarshalYAML) - new: spec/gloas/executionpayloadenvelope_test.go - new: spec/versionedsignedexecutionpayloadenvelope_test.go Verified: go build ./... clean; gosilent test ./... (2241) green; -race clean on the new tests; custom-gcl (attgo current-year plugin) 0 issues repo-wide; go generate ./spec/gloas byte-identical, no SSZ drift; go.mod and go.sum untouched (holiman/uint256 was already a direct dependency of this package). Five independent reviewers (spec, security, efficiency, code quality, simplicity) each approved, three of them re-running the mutation check themselves. Blockers / notes for next iteration: - The same unguarded .Dec() exists at four sites in spec/deneb and is now tracked as separate follow-up work: executionpayload and executionpayloadheader, JSON and YAML each. Their generated SSZ marshalers guard it (3 sites apiece) while all four hand-written marshalers do not -- the identical asymmetry fixed here, but in a shipped production fork rather than a devnet branch, so it is not gated on Glamsterdam and may warrant landing on master directly. A scripted sweep of every *_json.go and *_yaml.go under spec/ containing .Dec() found exactly these four unguarded sites and no others. - The broader pattern is worth a slice of its own: hand-written codecs systematically miss nil guards that their generated SSZ siblings have. This is the second instance found in as many sessions. - Two pre-existing inefficiencies in the touched function were measured and deliberately left alone: the bytes.ReplaceAll post-pass costs a full extra buffer copy on every call, and the per-transaction fmt.Sprintf is ~2 allocs/element. At 2000 transactions the whole call is ~10ms/17.5MB, so neither dominates, and the obvious hex.EncodeToString rewrite measured ~17% faster but ~3x the memory. Not worth changing while this path is diagnostic-only, and any real fix belongs in the JSON marshaler too, which shares the pattern. - The consensus-spec ssz_static vectors gate still cannot run: the newest ethereum/consensus-spec-tests release remains v1.6.0-beta.0 (2025-09-24), which predates gloas and ships no tests/mainnet/gloas directory, so the v1.7.0-alpha.12 vectors are unobtainable upstream rather than merely absent locally. This change touches only the YAML codec and go generate confirms no SSZ drift, so the gate has no bearing on it either way. --- spec/gloas/executionpayload_test.go | 38 +++++++++++ spec/gloas/executionpayload_yaml.go | 10 ++- spec/gloas/executionpayloadenvelope_test.go | 59 ++++++++++++++++ ...onedsignedexecutionpayloadenvelope_test.go | 67 +++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 spec/gloas/executionpayloadenvelope_test.go create mode 100644 spec/versionedsignedexecutionpayloadenvelope_test.go diff --git a/spec/gloas/executionpayload_test.go b/spec/gloas/executionpayload_test.go index 08a13a8ab..bd7074d31 100644 --- a/spec/gloas/executionpayload_test.go +++ b/spec/gloas/executionpayload_test.go @@ -19,6 +19,7 @@ import ( "testing" "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" "github.com/holiman/uint256" require "github.com/stretchr/testify/require" ) @@ -52,6 +53,43 @@ func TestExecutionPayloadMarshalJSON(t *testing.T) { } } +// 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) + }) + } +} + // TestExecutionPayloadUnmarshalJSON verifies that unmarshaling does not panic on // a transaction element too short to carry the "0x" framing, whose decoded // length (len-4)/2 would otherwise underflow to a negative make() size. diff --git a/spec/gloas/executionpayload_yaml.go b/spec/gloas/executionpayload_yaml.go index bae4e7c0c..83452f97b 100644 --- a/spec/gloas/executionpayload_yaml.go +++ b/spec/gloas/executionpayload_yaml.go @@ -20,6 +20,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/goccy/go-yaml" + "github.com/holiman/uint256" "github.com/pkg/errors" ) @@ -63,6 +64,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(), @@ -75,7 +83,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/executionpayloadenvelope_test.go b/spec/gloas/executionpayloadenvelope_test.go new file mode 100644 index 000000000..e4771a6af --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_test.go @@ -0,0 +1,59 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + require "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadEnvelopeString verifies that stringifying an envelope does not +// panic when its payload carries a nil BaseFeePerGas. The envelope's YAML marshaler +// embeds the payload, so String() descends into ExecutionPayload.MarshalYAML and +// depends on that marshaler's nil guard — this is the shape +// mock.Service.SignedExecutionPayloadEnvelope builds, which made logging a mocked or +// fetched envelope crash the caller. +func TestExecutionPayloadEnvelopeString(t *testing.T) { + tests := []struct { + name string + envelope *gloas.ExecutionPayloadEnvelope + // expected is a substring the rendering must contain. + expected string + }{ + { + // A nil payload is rendered as null by the YAML encoder, so this case + // never reaches the payload marshaler. + name: "ZeroValue", + envelope: &gloas.ExecutionPayloadEnvelope{}, + expected: "payload: null", + }, + { + name: "NilBaseFeePerGas", + envelope: &gloas.ExecutionPayloadEnvelope{Payload: &gloas.ExecutionPayload{}}, + expected: `base_fee_per_gas: '0'`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var str string + require.NotPanics(t, func() { + str = test.envelope.String() + }) + require.Contains(t, str, test.expected) + }) + } +} diff --git a/spec/versionedsignedexecutionpayloadenvelope_test.go b/spec/versionedsignedexecutionpayloadenvelope_test.go new file mode 100644 index 000000000..c68a3e2fd --- /dev/null +++ b/spec/versionedsignedexecutionpayloadenvelope_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 spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + 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) + }) + } +} From 2cc19843a49b1b142386cddf323a487e2f862121 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 13:04:27 +0200 Subject: [PATCH 09/37] register the seven Gloas SSE topics in SupportedEventTopics The Gloas ePBS transport port added typed handlers, api.EventsOpts fields and handler-func types for seven new SSE topics, and dispatch arms for all seven in handleEvent and checkEventSpecificHandler -- but never added the topic strings to apiv1.SupportedEventTopics, the allow-list checkEventsOpts gates on. Events() runs checkEventsOpts before it builds the subscription, so every one of those topics was refused with "unsupported event topic " and never reached the wire. The whole surface -- 7 handlers, 7 opts fields, 7 handler-func types, 14 dispatch arms -- was unreachable dead code. The seven registered here: execution_payload, execution_payload_available, execution_payload_bid, execution_payload_gossip, fast_confirmation, payload_attestation_message, proposer_preferences. Entries keep the map's alphabetical order, which now matches both switches in http/events.go one-for-one: allow-list, checkEventSpecificHandler and handleEvent are all 22 topics with empty set difference in both directions. Why CI stayed green, which is the more useful part: the existing test calls handleEvent directly, downstream of the gate, so it could not see the defect. But it is weaker than that -- http/main_test.go's TestMain returns without calling m.Run() unless HTTP_ADDRESS is set, and internal (package http) and external (package http_test) test files share one TestMain, so `go test ./http/` reports ok having executed zero tests. No test in http/ can fail a build in ordinary CI, whatever it asserts. That is why the unconditional guard for this regression class lives in api/v1, not next to the code it guards. Key decisions: - Coverage is split deliberately across two packages. api/v1's TestSupportedEventTopicsGloas asserts allow-list membership and is the only new test that runs without a configured endpoint. http's TestEventsGloasTopics exercises the real public Events() and runs against a live Gloas devnet. - The gate test asserts on the "no handler for event" error rather than only on success. checkEventsOpts emits "unsupported event topic X" from the allow-list check and "no handler for X event" from the handler check immediately after, so the second message is positive proof the topic cleared the allow-list -- and needs no network I/O or background subscription. Asserting only "no error" would be weaker, since Events() returns nil for everything once past the gate. - The routing test uses a zero-value &Service{}: handleEvent only dispatches, and every handleXEvent method has an unnamed receiver and takes its logger from the context, so none reads service state. Verified by reading all seven. That keeps a pure dispatch check free of any node dependency. - Event.UnmarshalJSON's separate topic switch was deliberately left at its original 15 topics and captured as follow-up work. Nothing regressed: before this change a Gloas topic was refused at both the Events() gate and there, so it is a second independent gap, not damage. The client's own event path never reaches it -- handleEvent builds &apiv1.Event{...} directly. Fixing it well also means confronting that the switch's typed result is discarded on the next line, which is a behavioural change to a shipped API and needs its own call. - A three-line comment on SupportedEventTopics records that it is the allow-list Events() validates against and is maintained separately from that switch, so the drift hazard is discoverable without hand-diffing two lists. Test: new TestEventsGloasTopics (http/events_test.go, 10 subtests through the public Events()), TestSupportedEventTopicsGloas (api/v1/event_test.go, 7 subtests), TestEventHandlerGloasRouting (http/events_internal_test.go, 7 subtests asserting the topic-specific handler wins over the generic one). None is vacuous, and each was checked differently. The gate test was RED before the fix: 15 of 16 subtests then failed with "unsupported event topic " while its UnknownTopic control passed. The api/v1 guard was mutation-checked -- deleting the seven entries fails exactly its seven subtests. The routing test covers pre-existing behaviour so it passed immediately; it was mutation-checked instead, by disabling one topic's specific-handler branch, which failed exactly that topic's subtest while its generic fallback still passed. Files changed: - modified: api/v1/event.go (7 map entries, comment, copyright) - modified: api/v1/event_test.go (TestSupportedEventTopicsGloas) - modified: http/events_test.go (TestEventsGloasTopics) - modified: http/events_internal_test.go (TestEventHandlerGloasRouting) Verified: go build ./... clean; gosilent test ./... 2248 green, and -race clean; custom-gcl (attgo) 0 issues repo-wide; go vet and gofmt clean on all four files (note .golangci.yml sets run.tests: false, so lint never inspects the test files -- go vet is what covers them). Against a live Gloas devnet (Lighthouse, minimal preset, head block version gloas) all three new tests pass, also under -race. A baseline run with these four files stashed produced a byte-identical set of 41 pre-existing http failures, so there are no regressions: those are the syncing devnet's "client is not synced" plus known devnet-scale and codec issues. Passing count went 140/199 to 170/229. Five independent reviewers ran; after one fix round all five approve. Blockers / notes for next iteration: - No http/ test can gate CI, per the TestMain analysis above. Until that is addressed, any http-only regression guard is invisible to CI, and this class of defect can ship green again. Tracked as follow-up work. - Event.UnmarshalJSON still rejects all seven topics; confirmed by probe, so a generic apiv1.Event JSON round-trip fails for Gloas topics. Follow-up. - A severe pre-existing bug was found and reproduced in multi/events.go while chasing a reviewer's aside: for active clients, activeHandler.opts is the substituted options struct, so each wrapper method forwards into itself (h.opts.Handler == h.genericHandler). It self-recurses to a fatal, unrecoverable stack overflow on the first event from the primary active client, and the caller's handlers are dropped entirely since only Common is copied. Reproduced with a temporary internal probe, since activeHandler is unexported; the probe was deleted and multi/ is untouched here. Unrelated to Gloas -- multi/events.go predates it -- but it means multi.Events() cannot work today. Tracked as follow-up, and worth prioritising. - Two further pre-existing issues captured as follow-up: ExecutionPayloadEvent defaults execution_optimistic to false for execution_payload_gossip, whose spec schema has no such field, so the two topics decode to byte-identical structs and a merely gossip-validated payload can read as fully verified; and log forging via unvalidated RawJSON of attacker-controlled SSE payloads at 22 sites in http/events.go, where a hostile node can emit a second "level" key and downgrade a logged error to info. - The consensus-spec ssz_static vectors gate does not apply: nothing under spec/ was touched, so there is no SSZ drift to check. Those v1.7.0-alpha.12 vectors remain unobtainable upstream in any case. - One unidentified transient test failure appeared in a single CI-mode run and did not reproduce in three further runs, one of which reported all 19 packages passing individually. The compressed output did not name it. Not in a package this change can affect (api/v1 passed 549/549 in that detail run), but worth watching for a flake. --- api/v1/event.go | 43 ++++++----- api/v1/event_test.go | 30 +++++++- http/events_internal_test.go | 111 +++++++++++++++++++++++++++- http/events_test.go | 136 ++++++++++++++++++++++++++++++++++- 4 files changed, 300 insertions(+), 20 deletions(-) diff --git a/api/v1/event.go b/api/v1/event.go index cb271b0d4..4568f56b1 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 @@ -33,23 +33,32 @@ type Event struct { Data any } -// SupportedEventTopics is a map of supported event topics. +// SupportedEventTopics is a map of supported event topics. It is the allow-list +// against which the HTTP client validates Events() subscriptions, and is +// maintained separately from the topic switch in Event.UnmarshalJSON below. var SupportedEventTopics = map[string]bool{ - "attestation": true, - "attester_slashing": true, - "blob_sidecar": true, - "block": true, - "block_gossip": true, - "bls_to_execution_change": true, - "chain_reorg": true, - "contribution_and_proof": true, - "data_column_sidecar": true, - "finalized_checkpoint": true, - "head": true, - "payload_attributes": true, - "proposer_slashing": true, - "single_attestation": true, - "voluntary_exit": true, + "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, + "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. diff --git a/api/v1/event_test.go b/api/v1/event_test.go index c225e1e4b..9f8827f38 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 @@ -111,3 +111,31 @@ func TestEvent(t *testing.T) { }) } } + +// TestSupportedEventTopicsGloas confirms the allow-list carries the event +// topics introduced by the Gloas (ePBS) fork. SupportedEventTopics is what the +// http client gates every Events() subscription on, so a topic missing from it +// is unreachable from the public API however completely the rest of the client +// handles it. +// +// This is the guard that runs unconditionally: the http package's tests are +// skipped wholesale unless HTTP_ADDRESS names a beacon node, so none of them +// can fail a build over a regression here. +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; Events() will reject it", topic) + }) + } +} diff --git a/http/events_internal_test.go b/http/events_internal_test.go index 5b9561e8b..ccdcf3003 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 @@ -309,6 +309,115 @@ func TestEventHandler(t *testing.T) { } } +// TestEventHandlerGloasRouting confirms each Gloas event topic routes to its +// own handler in preference to the generic one, which pins each topic to the +// EventsOpts field it is meant to read. TestEventHandler above already covers +// the generic-handler path for these topics, and a topic wired to the wrong +// field would still satisfy that test. +// +// A zero-value Service suffices here: handleEvent only dispatches, and every +// handleXEvent method has an unnamed receiver and takes its logger from the +// context, so none of them reads service state. +func TestEventHandlerGloasRouting(t *testing.T) { + specificHandled, genericHandled := false, false + + tests := []struct { + name string + message *sse.Event + opts *api.EventsOpts + }{ + { + name: "ExecutionPayload", + message: &sse.Event{ + Event: []byte("execution_payload"), + Data: versionWrap([]byte(`{"slot":"1","builder_index":"2","block_hash":"0xaa00000000000000000000000000000000000000000000000000000000000000","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000","execution_optimistic":false}`)), + }, + opts: &api.EventsOpts{ + ExecutionPayloadHandler: func(context.Context, *apiv1.ExecutionPayloadEvent) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "ExecutionPayloadAvailable", + message: &sse.Event{ + Event: []byte("execution_payload_available"), + Data: []byte(`{"block_root":"0xcc00000000000000000000000000000000000000000000000000000000000000","slot":"3"}`), + }, + opts: &api.EventsOpts{ + ExecutionPayloadAvailableHandler: func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "ExecutionPayloadBid", + message: &sse.Event{ + Event: []byte("execution_payload_bid"), + Data: versionWrap(mustEventJSON(t, &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}})), + }, + opts: &api.EventsOpts{ + ExecutionPayloadBidHandler: func(context.Context, *gloas.SignedExecutionPayloadBid) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "ExecutionPayloadGossip", + message: &sse.Event{ + Event: []byte("execution_payload_gossip"), + Data: versionWrap([]byte(`{"slot":"1","builder_index":"2","block_hash":"0xaa00000000000000000000000000000000000000000000000000000000000000","block_root":"0xbb00000000000000000000000000000000000000000000000000000000000000","execution_optimistic":false}`)), + }, + opts: &api.EventsOpts{ + ExecutionPayloadGossipHandler: func(context.Context, *apiv1.ExecutionPayloadEvent) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "FastConfirmation", + message: &sse.Event{ + Event: []byte("fast_confirmation"), + Data: []byte(`{"slot":"4","block":"0xdd00000000000000000000000000000000000000000000000000000000000000","current_slot":"5"}`), + }, + opts: &api.EventsOpts{ + FastConfirmationHandler: func(context.Context, *apiv1.FastConfirmationEvent) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "PayloadAttestationMessage", + message: &sse.Event{ + Event: []byte("payload_attestation_message"), + Data: versionWrap(mustEventJSON(t, &gloas.PayloadAttestationMessage{Data: &gloas.PayloadAttestationData{}})), + }, + opts: &api.EventsOpts{ + PayloadAttestationMessageHandler: func(context.Context, *gloas.PayloadAttestationMessage) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + { + name: "ProposerPreferences", + message: &sse.Event{ + Event: []byte("proposer_preferences"), + Data: versionWrap(mustEventJSON(t, &gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}})), + }, + opts: &api.EventsOpts{ + ProposerPreferencesHandler: func(context.Context, *gloas.SignedProposerPreferences) { specificHandled = true }, + Handler: func(*apiv1.Event) { genericHandled = true }, + }, + }, + } + + s := &Service{} + ctx := zerolog.New(&bytes.Buffer{}).WithContext(context.Background()) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specificHandled, genericHandled = false, false + s.handleEvent(ctx, test.message, test.opts) + require.True(t, specificHandled, "topic-specific handler was not called") + require.False(t, genericHandled, "generic handler was called despite a topic-specific one being supplied") + }) + } +} + // mustEventJSON marshals v to JSON for use as synthetic SSE event data, failing // the test if marshaling errors. Building gloas event payloads from constructed // values (rather than hand-written hex) keeps the fixed-length signature and diff --git a/http/events_test.go b/http/events_test.go index 45b78dacb..b22bc1d2f 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,6 +22,7 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/stretchr/testify/require" ) @@ -63,3 +64,136 @@ 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 apiv1.SupportedEventTopics, via +// checkEventsOpts, before it builds the subscription, so a topic absent from +// that allow-list is refused here and never reaches the wire, however +// completely the dispatcher downstream handles it. TestEventHandler and +// TestEventHandlerGloasRouting both call handleEvent directly, downstream of +// this check, so neither exercises it. +// +// 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 +// checkEventSpecificHandler consults for 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 allow-list +// 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 + // allow-list 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", + }, + { + 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.ExecutionPayloadEvent) {}, + }, + }, + { + 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 allow-list does not carry 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", + }, + } + + 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) + } + }) + } +} From 35e9662bc5161c327699b3aa01beae1270a58afd Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 14:08:14 +0200 Subject: [PATCH 10/37] retype the Gloas arms of the versioned aggregate-and-proof wrappers Both spec.VersionedAggregateAndProof.Gloas and spec.VersionedSignedAggregateAndProof.Gloas were declared *electra.{,Signed}AggregateAndProof. Under Gloas, Attestation is a ProgressiveContainer whose aggregation_bits are a ProgressiveBitlist (consensus-spec v1.7.0-alpha.12, specs/gloas/beacon-chain.md); electra's are a regular Bitlist. A progressive bitlist merkleizes into a right-leaning chain of subtrees whose depth follows the actual data length, while a regular bitlist pads to a fixed depth derived from the type limit, so the two produce different hash tree roots for identical bytes. Measured on one logical aggregate: gloas 0xc565330d..., electra 0xbcf25de3.... That root is the BLS signing root. VersionedAggregateAndProof.HashTreeRoot() is what a validator signs before wrapping the signature for submission, so with the electra container reached from the Gloas arm the validator signed the wrong preimage and the beacon node rejected the aggregate as invalid-signature, with nothing in the payload to indicate why. The gloas containers ported earlier were meanwhile referenced nowhere outside spec/gloas. The fix is one retyped field per wrapper. Every DataVersionGloas accessor arm already compiles unchanged against the gloas containers, since the field names along Message.Aggregate.Data match electra's one for one. Key decisions: - Both wrappers are fixed, not just the signed one. The signed wrapper alone would have made the submit path type-correct while HashTreeRoot() on the unsigned wrapper still returned the electra root, leaving the invalid-signature symptom exactly as it was. They are one defect: the validator flow is build unsigned, hash, sign, wrap, submit. The unsigned wrapper has no callers anywhere in this library, which is why the mistyping went unnoticed; it is public API for downstream signers. - Additive, and safe to retype: both Gloas arms are new on this branch and unreleased, so no downstream consumer exists. The one in-repo consumer, createUnversionedAggregates, appends the arm into a []any for JSON marshalling and needed no change. A welcome side effect is that assigning a populated electra value into a Gloas arm is now a compile error rather than a silently wrong signature. - The retyped fields carry a three-line comment answering the question a reader will ask, namely why Gloas diverges when Fulu does not. Its absence is what let a mechanical copy of the Fulu line become a signing bug. - The JSON wire shape is unaffected: gloas and electra aggregate marshallers are structurally identical, and the progressive/regular distinction is purely a merkleization concept. Only the root changes. - No arm was added to attestationpool.go or any decode path. Gloas aggregates arrive wire-identical to electra and VersionedAttestation already types its Gloas arm as *gloas.Attestation, so the fetch-to-submit path now type-checks end to end with no conversion. Test: spec/versionedaggregateandproof_test.go and spec/versionedsignedaggregateandproof_test.go, four tests covering the Gloas arm of both wrappers: the hash tree root, the fetch-aggregate-sign-submit path, and every accessor including its missing-arm error. The root test asserts both that the Gloas root differs from electra's for the same logical aggregate and that it equals a pinned value. Both are needed, and finding out why was the useful part of this change. Mutation testing showed the difference assertion alone is insensitive: swapping the progressive bitlist mixin for a regular one inside the generated codec, or returning a zero root, still leaves the root unequal to electra's, so the assertion passed while the merkleization was broken. An inequality pins a boundary, not a value. The pin closes that, and its comment is explicit that it guards drift in this repository's own codec and is not independent spec verification, which needs the consensus-spec ssz_static vectors. Every test was mutation-checked rather than assumed: corrupting the unsigned wrapper's HashTreeRoot arm to delegate to the inner attestation fails the root test; an off-by-one in the AggregatorIndex arm fails the fetch-to-submit test and exactly the AggregatorIndex subtest, leaving the other five passing; a zeroed SelectionProof arm fails only that subtest. A first version of the root test also carried a "Fulu control" asserting the Fulu and Electra roots match. It was deleted: both fields are the same Go type and it was handed the same pointer, so it asserted only that the hasher is deterministic, not what its comment claimed. A reflection-based test asserting the ssz-type struct tag reachable through each arm was written first, as the only way to get an assertion-level failure out of a type change, and deleted once it had served that purpose, since the pinned root subsumes it. Files changed: - modified: spec/versionedaggregateandproof.go (import, comment, field) - modified: spec/versionedsignedaggregateandproof.go (import, comment, field) - modified: http/submitaggregateattestations_internal_test.go (gloas types; its comment had asserted the now-disproven premise that the Gloas aggregate wire format is unchanged from electra) - new: spec/versionedaggregateandproof_test.go - new: spec/versionedsignedaggregateandproof_test.go Verified: go build ./... and go vet ./... clean; gosilent test ./... 2261 green, and -race clean on spec/...; custom-gcl 0 issues repo-wide; gofmt clean on all five files; go generate ./spec/gloas byte-identical, no SSZ drift; go.mod and go.sum untouched. Note .golangci.yml sets run.tests: false, so lint never inspects the test files and go vet is what covers them. Five independent reviewers ran. Spec compliance independently recomputed the pinned root two ways outside this repository, once from a hand-written transcription of the merkleization rules in ssz/simple-serialize.md and once via eth-remerkleable at the version consensus-specs itself pins, both matching byte for byte. It also read the consensus-specs build tooling rather than only the markdown, confirming that a container not redefined in a later fork is carried forward as source text and recompiled in that fork's namespace, so Gloas's AggregateAndProof resolves to Gloas's Attestation even though gloas/validator.md carries no explicit "Modified AggregateAndProof" section the way electra/validator.md does. Efficiency benchmarked the two merkleizations and found progressive neither materially faster nor slower at any realistic size. After one fix round, which cut the test files from 416 lines to 311, all five approve. Blockers / notes for next iteration: - Two pre-existing defects found during review and captured as separate follow-up work, both fork-independent rather than Gloas-specific. First, both versioned aggregate wrappers fail open on partially-populated containers: HashTreeRoot() returns a signable root when Aggregate or Data is nil, and the two states produce the identical root, so the root is not injective over them. Since this method feeds a BLS signer, that is the more interesting of the two. The accessors panic on the same partial structs. Neither is remote-triggerable because neither wrapper is a deserialization target. Second, createUnversionedAggregates POSTs a body of [null] when Version names a nil arm, since it never checks the selected arm is populated even though the wrapper has an IsEmpty() method that would detect it. - The consensus-spec ssz_static vectors gate does not apply here: nothing under spec/gloas changed and go generate confirms no drift. Those v1.7.0-alpha.12 vectors also remain unobtainable upstream, the newest published release still predating gloas, which is why the pinned root is a drift guard rather than spec verification. - The gloas aggregate containers this change makes reachable are not in the ethpandaops fork at all, on any branch: they were authored during the earlier types work under a port framing. Their shapes were checked against the spec here and match, but the same caveat may apply to other types that work introduced, and is worth a look. - No live-network verification was attempted, and none is needed: the change is a compile-time retype whose observable effect is a hash tree root, verified against an independent implementation of the merkleization rules. Submitting a real Gloas aggregate would additionally need a validator able to sign one. --- ...bmitaggregateattestations_internal_test.go | 12 +- spec/versionedaggregateandproof.go | 6 +- spec/versionedaggregateandproof_test.go | 165 ++++++++++++++++++ spec/versionedsignedaggregateandproof.go | 6 +- spec/versionedsignedaggregateandproof_test.go | 146 ++++++++++++++++ 5 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 spec/versionedaggregateandproof_test.go create mode 100644 spec/versionedsignedaggregateandproof_test.go diff --git a/http/submitaggregateattestations_internal_test.go b/http/submitaggregateattestations_internal_test.go index 24b3ac63f..8f5973013 100644 --- a/http/submitaggregateattestations_internal_test.go +++ b/http/submitaggregateattestations_internal_test.go @@ -17,17 +17,17 @@ import ( "testing" "github.com/attestantio/go-eth2-client/spec" - "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/stretchr/testify/require" ) // TestCreateUnversionedAggregatesGloas verifies that a Gloas-versioned aggregate and proof is -// unwrapped for submission. Under Gloas the aggregate and proof wire format is unchanged from -// Electra (VersionedSignedAggregateAndProof.Gloas is *electra.SignedAggregateAndProof), so the -// arm forwards the Electra-typed value; without it the switch hits its default and errors. +// unwrapped for submission. Gloas carries its own container (gloas.Attestation merkleizes its +// aggregation bits as a progressive bitlist), so the arm must forward the Gloas-typed value +// unchanged; without the arm the switch hits its default and errors. func TestCreateUnversionedAggregatesGloas(t *testing.T) { - aggregate := &electra.SignedAggregateAndProof{ - Message: &electra.AggregateAndProof{AggregatorIndex: 42}, + aggregate := &gloas.SignedAggregateAndProof{ + Message: &gloas.AggregateAndProof{AggregatorIndex: 42}, } aggregateAndProofs := []*spec.VersionedSignedAggregateAndProof{ {Version: spec.DataVersionGloas, Gloas: aggregate}, diff --git a/spec/versionedaggregateandproof.go b/spec/versionedaggregateandproof.go index 70e478f56..93b43e4a0 100644 --- a/spec/versionedaggregateandproof.go +++ b/spec/versionedaggregateandproof.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,7 +31,10 @@ type VersionedAggregateAndProof struct { Deneb *phase0.AggregateAndProof Electra *electra.AggregateAndProof Fulu *electra.AggregateAndProof - Gloas *electra.AggregateAndProof + // Gloas has its own container because gloas.Attestation merkleizes its aggregation bits as a + // progressive bitlist, giving a different hash tree root (and so a different signing root) + // from the electra container. + Gloas *gloas.AggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. diff --git a/spec/versionedaggregateandproof_test.go b/spec/versionedaggregateandproof_test.go new file mode 100644 index 000000000..d8aadc23a --- /dev/null +++ b/spec/versionedaggregateandproof_test.go @@ -0,0 +1,165 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "fmt" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedAggregateAndProofHashTreeRootGloas asserts that the Gloas arm's hash tree root is +// computed via the progressive-bitlist merkleization, by showing it differs from the root the +// electra container produces for the same logical aggregate. +// +// Under Gloas (consensus-spec v1.7.0-alpha.12, specs/gloas/beacon-chain.md) Attestation is a +// ProgressiveContainer whose aggregation_bits are a ProgressiveBitlist; electra's are a regular +// Bitlist, so the two merkleize to different hash tree roots. +func TestVersionedAggregateAndProofHashTreeRootGloas(t *testing.T) { + electraAggregate, gloasAggregate := equivalentAggregates() + + gloasRoot, err := (&spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: gloasAggregate, + }).HashTreeRoot() + require.NoError(t, err) + + electraRoot, err := (&spec.VersionedAggregateAndProof{ + Version: spec.DataVersionElectra, + Electra: electraAggregate, + }).HashTreeRoot() + require.NoError(t, err) + + require.NotEqual(t, electraRoot, gloasRoot, + "the Gloas root must differ from electra's: aggregation bits merkleize progressively") + + // Pin the root itself, so that a change to the progressive merkleization is caught rather + // than merely producing a different-but-still-not-electra value. This value is generated from + // this repository's own codec, so it guards against drift; it is not independent verification + // against the spec, which needs the consensus-spec ssz_static vectors. + require.Equal(t, "0xc565330dfc3b575b20935a7f217a64119b90f86c0e987b023bbf6d94648334ce", + fmt.Sprintf("%#x", gloasRoot), + "the Gloas aggregate root changed: check go generate ./spec/gloas and the progressive merkleization") +} + +// TestVersionedAggregateAndProofGloasAccessors covers every accessor's DataVersionGloas arm on the +// unsigned wrapper, asserting each reads the gloas container and reports a missing arm rather than +// panicking on a nil pointer. +// +// This wrapper has no callers inside this library (it exists for downstream signers, which is why +// its mistyped Gloas arm went unnoticed), so these are the only tests exercising its Gloas path. +func TestVersionedAggregateAndProofGloasAccessors(t *testing.T) { + _, aggregate := equivalentAggregates() + populated := &spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: aggregate, + } + empty := &spec.VersionedAggregateAndProof{Version: spec.DataVersionGloas} + + t.Run("AggregatorIndex", func(t *testing.T) { + got, err := populated.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), got) + + _, err = empty.AggregatorIndex() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("SelectionProof", func(t *testing.T) { + got, err := populated.SelectionProof() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, got) + + _, err = empty.SelectionProof() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("HashTreeRoot", func(t *testing.T) { + _, err := empty.HashTreeRoot() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "aggregator_index") + require.Empty(t, empty.String()) + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, empty.IsEmpty()) + }) +} + +// equivalentAggregates returns an electra and a gloas aggregate and proof carrying identical +// logical content, so that any difference in their hash tree roots is attributable to +// merkleization alone. +// +// It, along with the aggregationBits helper below, is declared here and shared with +// versionedsignedaggregateandproof_test.go. +func equivalentAggregates() (*electra.AggregateAndProof, *gloas.AggregateAndProof) { + data := &phase0.AttestationData{ + Slot: 42, + Index: 1, + BeaconBlockRoot: phase0.Root{0x01}, + Source: &phase0.Checkpoint{Epoch: 1, Root: phase0.Root{0x02}}, + Target: &phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x03}}, + } + + signature := phase0.BLSSignature{0xaa} + selectionProof := phase0.BLSSignature{0xbb} + + return &electra.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: &electra.Attestation{ + AggregationBits: aggregationBits(), + Data: data, + Signature: signature, + CommitteeBits: committeeBits(), + }, + SelectionProof: selectionProof, + }, &gloas.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: &gloas.Attestation{ + AggregationBits: aggregationBits(), + Data: data, + Signature: signature, + CommitteeBits: committeeBits(), + }, + SelectionProof: selectionProof, + } +} + +// aggregationBits returns a bitlist with two bits set, short enough that the progressive and +// regular merkleizations of it are both exercised on a single chunk. +func aggregationBits() bitfield.Bitlist { + bits := bitfield.NewBitlist(8) + bits.SetBitAt(0, true) + bits.SetBitAt(3, true) + + return bits +} + +// committeeBits returns a committee bitvector with a single committee selected. +func committeeBits() bitfield.Bitvector64 { + bits := bitfield.NewBitvector64() + bits.SetBitAt(0, true) + + return bits +} diff --git a/spec/versionedsignedaggregateandproof.go b/spec/versionedsignedaggregateandproof.go index 9627c46e0..f2804291e 100644 --- a/spec/versionedsignedaggregateandproof.go +++ b/spec/versionedsignedaggregateandproof.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,7 +31,10 @@ type VersionedSignedAggregateAndProof struct { Deneb *phase0.SignedAggregateAndProof Electra *electra.SignedAggregateAndProof Fulu *electra.SignedAggregateAndProof - Gloas *electra.SignedAggregateAndProof + // Gloas has its own container because gloas.Attestation merkleizes its aggregation bits as a + // progressive bitlist, giving a different hash tree root (and so a different signing root) + // from the electra container. + Gloas *gloas.SignedAggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. diff --git a/spec/versionedsignedaggregateandproof_test.go b/spec/versionedsignedaggregateandproof_test.go new file mode 100644 index 000000000..6bfe2983e --- /dev/null +++ b/spec/versionedsignedaggregateandproof_test.go @@ -0,0 +1,146 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedSignedAggregateAndProofGloasFetchToSubmit walks a Gloas aggregate through the +// sequence a validator actually performs, asserting the types line up at every hop with no +// conversion: +// +// fetch: AggregateAttestation returns a VersionedAttestation whose Gloas arm is a +// *gloas.Attestation +// aggregate: that attestation is placed directly into a VersionedAggregateAndProof +// sign: HashTreeRoot on that wrapper yields the signing root +// submit: the signed result is wrapped in a VersionedSignedAggregateAndProof +// +// The hop that matters is placing the fetched *gloas.Attestation into the aggregate: while the +// Gloas arms were electra-typed this step did not compile, and the aggregate had to be rebuilt as +// an electra value, which is what made the signing root wrong. Each assignment below is therefore +// itself the assertion; the accessor checks confirm the wrappers resolve their Gloas arms. +func TestVersionedSignedAggregateAndProofGloasFetchToSubmit(t *testing.T) { + _, aggregate := equivalentAggregates() + + // Fetch: the shape AggregateAttestation returns for a Gloas node. + fetched := &spec.VersionedAttestation{ + Version: spec.DataVersionGloas, + Gloas: aggregate.Aggregate, + } + + // The fetched wrapper resolves its Gloas arm through the versioned accessors. + fetchedBits, err := fetched.AggregationBits() + require.NoError(t, err) + require.Equal(t, aggregationBits(), fetchedBits) + + // Aggregate: the fetched attestation is carried straight through, not converted. + unsigned := &spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: fetched.Gloas, + SelectionProof: phase0.BLSSignature{0xbb}, + }, + } + + // Sign: the root the validator signs comes from the unsigned wrapper. + _, err = unsigned.HashTreeRoot() + require.NoError(t, err) + + // Submit: the signed aggregate keeps the same message. + signed := &spec.VersionedSignedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedAggregateAndProof{ + Message: unsigned.Gloas, + Signature: phase0.BLSSignature{0xcc}, + }, + } + + // The signed wrapper reports the values that came off the wire, through its accessors. + aggregatorIndex, err := signed.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), aggregatorIndex) + + slot, err := signed.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(42), slot) +} + +// TestVersionedSignedAggregateAndProofGloasAccessors covers every accessor's DataVersionGloas arm, +// asserting each reads the gloas container rather than erroring or returning a zero value, and that +// each reports a missing arm rather than panicking on a nil pointer. +func TestVersionedSignedAggregateAndProofGloasAccessors(t *testing.T) { + _, aggregate := equivalentAggregates() + populated := &spec.VersionedSignedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedAggregateAndProof{ + Message: aggregate, + Signature: phase0.BLSSignature{0xcc}, + }, + } + empty := &spec.VersionedSignedAggregateAndProof{Version: spec.DataVersionGloas} + + t.Run("AggregatorIndex", func(t *testing.T) { + got, err := populated.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), got) + + _, err = empty.AggregatorIndex() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("SelectionProof", func(t *testing.T) { + got, err := populated.SelectionProof() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, got) + + _, err = empty.SelectionProof() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("Signature", func(t *testing.T) { + got, err := populated.Signature() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xcc}, got) + + _, err = empty.Signature() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("Slot", func(t *testing.T) { + got, err := populated.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(42), got) + + _, err = empty.Slot() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("String", func(t *testing.T) { + // The gloas container's own String, reached through the arm, reports its fields. + require.Contains(t, populated.String(), "aggregator_index") + require.Empty(t, empty.String()) + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, empty.IsEmpty()) + }) +} From 744b91904039ca26f6b172263785c24d93c147c1 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 15:17:07 +0200 Subject: [PATCH 11/37] restore static-SSZ decode coverage in the http test suite Five http endpoints decode SSZ through one of two branches: a dynssz codec built from the spec the node reports, or the statically generated mainnet-preset codec. The devnet enablement passed WithCustomSpecSupport(true) to all four service constructors in http/main_test.go, so every test took the dynssz branch and the static one -- the default, and therefore the path every mainnet client that does not opt in actually runs -- had no coverage left. A regression in it would have shipped with a green suite. The flag is correct and stays. What is added is a default-mode path beside it. Why the coverage cannot come from a live node: the static codecs are generated at the mainnet preset, and the only Glamsterdam endpoint available is minimal-preset. Measured, a mainnet-preset gloas.BeaconState needs 3,134,845 bytes of fixed fields while the devnet serves 110,789, so the static decode rejects it outright -- RANDAO_MIXES alone is 2MiB at mainnet against 2KiB at minimal. The new unit tests therefore marshal their own bodies in-process with the same generated codecs and need no beacon node. Coverage is per fork arm, because the branch is hand-copied per arm in the production switch: 8 arms for beaconStateFromSSZ, 7 for signedBeaconBlockFromSSZ, 12 for beaconBlockProposalFromSSZ (blinded and full), plus blobsFromSSZ and blobSidecarsFromSSZ with their empty-body early return, which no other test reaches. 27 rows against 27 independently breakable lines. Key decisions: - The seam is one constructor, newTestService(ctx, customSpecSupport, params...), which replaces four near-identical http.New blocks. A test wanting the other mode calls it with false; no address, timeout or bearer token handling is restated. An earlier draft wrapped it in a second shared global plus a testServiceDefaultSpec/testServiceWithSpecMode accessor pair, and that needed a sync.Map to stop the weight-1 coordinator semaphore being acquired twice by one test and deadlocking. All of it was deleted on review: with the single caller using the constructor directly nothing acquires twice, so testService keeps its original acquire block verbatim. The guard was also narrower than it looked, since t.Run yields a fresh *testing.T and a parent/subtest pair would still have deadlocked. Removing the cause beat guarding the symptom, and main_test.go now nets 16 fewer lines. - Assertions read through the versioned wrapper's own Slot() accessor rather than checking the fork field non-nil. This matters: the decoders assign response.Data. = &.BeaconState{} before they branch, so NotNil can never fail, and a zero-value container re-marshals byte-identically to a fresh allocation. The first draft of the beacon-state test asserted NotNil, passed, and was vacuous. Slot() fails both on a skipped decode (slot 0) and on a body landing in another fork's arm. - The live test asserts the default mode's expected failure on a non-mainnet preset instead of skipping there. A skip would never run in the only environment available, and the assertion doubles as a fail-open guard: deleting a static decode call produces no error at all, just a zero-valued state that reads as real. It is scoped to the beacon state because that is the one SSZ endpoint the devnet serves in both modes -- the block endpoints have no gloas arm yet and the devnet head carries no blobs. - beaconstate_test.go's ad-hoc jsonService is a fifth copy of the same bearer-token branching and was deliberately left alone. A second, structurally different copy lives in events_internal_test.go, which is package http and so cannot reach a package http_test helper at all; a correct de-duplication spans that boundary and belongs to the tracked de-duplication work, not here. Files changed: - modified: http/main_test.go (newTestService replaces four http.New blocks; 35 insertions, 51 deletions) - new: http/staticsszdecode_internal_test.go (static branch, all five decoders, 31 subtests) - new: http/staticsszdecode_test.go (both spec modes through the real endpoint) Verified: go build ./... , go vet ./http/ and gofmt clean; gosilent test ./... 2261 green; custom-gcl 0 issues repo-wide; go generate ./spec/gloas byte-identical. Against the live Gloas devnet (Lighthouse, minimal preset, head gloas) the 33 new tests pass, also under -race, and the full http suite goes 233 pass / 60 fail with the 60-name failure set byte-identical to the pre-change baseline -- zero regressions, +39 passes, exactly the 33 new subtests plus their 6 parents. Note .golangci.yml sets run.tests: false, so lint never inspected these three files; go vet and gofmt are what cover them. Coverage is real, not assumed. Seven distinct mutations were applied to the production decoders and each was caught by exactly its own subtest and no other: removing the static decode in the beaconstate electra and gloas arms, the signedbeaconblock deneb arm, the proposal capella-full and electra-blinded arms, blobs and blobsidecars; inverting the branch condition so default mode reaches the nil dynssz codec panics as it should. Two were re-run after the review trims. Every production file was confirmed clean at HEAD afterwards. Blockers / notes for next iteration: - These tests still cannot fail CI. TestMain returns without calling m.Run() unless HTTP_ADDRESS is set, and the internal and external test files share that one TestMain, so go test ./http/ reports ok having executed zero tests -- including the new tests, which make no network calls of their own. The coverage restored here only becomes a CI gate once that is fixed; it is tracked separately and criterion 5 explicitly held it out of scope, so TestMain is untouched. - SignedBeaconBlock against the devnet fails in both modes with "unhandled block version gloas", which is the deferred ePBS block/proposal endpoint work, not a defect here. BlobSidecars returns 400 "block is pre-Deneb and has no blobs" for a gloas head block, which looks like a node-side quirk rather than a library one; worth a second look if blob coverage is wanted against this devnet. - The 60 pre-existing devnet failures are the syncing node's "client is not synced" plus the known JSON beacon-state codec bug and devnet-scale mismatches. Count differs from earlier sessions only because sync distance has grown; the failure set was captured before and after and is identical. - Two hand-written service constructions remain outside the new seam, in beaconstate_test.go and events_internal_test.go. The latter is in package http and cannot use a package http_test helper, so any real fix needs an exported or duplicated seam. --- http/main_test.go | 86 +++--- http/staticsszdecode_internal_test.go | 377 ++++++++++++++++++++++++++ http/staticsszdecode_test.go | 77 ++++++ 3 files changed, 489 insertions(+), 51 deletions(-) create mode 100644 http/staticsszdecode_internal_test.go create mode 100644 http/staticsszdecode_test.go diff --git a/http/main_test.go b/http/main_test.go index bc9ee1290..d4d8cd000 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -34,7 +34,7 @@ import ( var timeout = 5 * time.Minute // Global HTTP service instance shared across all tests to reduce connection overhead. -var globalHTTPService interface{} +var globalHTTPService any // testCoordinator controls how many tests can run concurrently to avoid overwhelming the endpoint. // This is configured via HTTP_TEST_CONCURRENCY (default: 1 for sequential execution). @@ -65,6 +65,34 @@ func TestMain(m *testing.M) { } } +// newTestService creates an HTTP service against HTTP_ADDRESS, authenticating with +// HTTP_BEARER_TOKEN when one is set. customSpecSupport picks which branch the SSZ +// decoders take, so a caller that needs a mode other than the suite's default gets it +// without restating the address, timeout and token handling. +func newTestService(ctx context.Context, + customSpecSupport bool, + params ...http.Parameter, +) ( + client.Service, + error, +) { + // Custom spec support is required for the interim Glamsterdam devnet's minimal + // preset; it is a no-op superset for mainnet-preset endpoints (see ADR-0003). + 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...)...) +} + // initGlobalHTTPService creates a single HTTP service instance that all tests will share. // This reduces connection overhead and makes tests more efficient. func initGlobalHTTPService() { @@ -73,37 +101,12 @@ 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")), - // Required for the interim Glamsterdam devnet's minimal preset; a - // no-op superset for mainnet-preset endpoints (see ADR-0003). - http.WithCustomSpecSupport(true), - 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")), - // Required for the interim Glamsterdam devnet's minimal preset; a - // no-op superset for mainnet-preset endpoints (see ADR-0003). - http.WithCustomSpecSupport(true), - 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 we can't create the service, tests will fail anyway; leave the global nil and + // let the individual tests handle the error. + if service, err := newTestService(ctx, true, http.WithAllowDelayedStart(true)); err == nil { + globalHTTPService = service } - globalHTTPService = service } // testService returns an HTTP service for testing. @@ -130,30 +133,11 @@ 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")), - // Required for the interim Glamsterdam devnet's minimal preset; a - // no-op superset for mainnet-preset endpoints (see ADR-0003). - http.WithCustomSpecSupport(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")), - // Required for the interim Glamsterdam devnet's minimal preset; a - // no-op superset for mainnet-preset endpoints (see ADR-0003). - http.WithCustomSpecSupport(true), - ) - } - + service, err := newTestService(ctx, true) if err != nil { t.Fatalf("Failed to create HTTP service: %v", err) } + return service } diff --git a/http/staticsszdecode_internal_test.go b/http/staticsszdecode_internal_test.go new file mode 100644 index 000000000..207efecc5 --- /dev/null +++ b/http/staticsszdecode_internal_test.go @@ -0,0 +1,377 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +// Coverage for the static SSZ decode branch, i.e. the one a service takes when it has +// not opted in to WithCustomSpecSupport. That is the default, and so the path every +// mainnet-preset client actually runs, but no live test can reach it: the interim +// Glamsterdam devnet serves the minimal preset, whose bodies the generated +// mainnet-preset codecs reject outright (see ADR-0003). The bodies here are therefore +// marshalled in-process by the same generated codecs. +// +// Each test drives a decoder on a zero-value &Service{}, whose customSpecSupport is +// false: that branch reads nothing from the service, so no beacon node is involved. +// +// Note these tests are still gated by TestMain, which runs nothing unless HTTP_ADDRESS +// is set, even though they make no network calls of their own. + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + apiv1bellatrix "github.com/attestantio/go-eth2-client/api/v1/bellatrix" + apiv1capella "github.com/attestantio/go-eth2-client/api/v1/capella" + apiv1deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" + apiv1electra "github.com/attestantio/go-eth2-client/api/v1/electra" + apiv1fulu "github.com/attestantio/go-eth2-client/api/v1/fulu" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/fulu" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// staticSlot is the slot every body below is built at. Any value that is not the zero +// value works; the point is that a skipped decode leaves 0 behind. +const staticSlot = phase0.Slot(11235813) + +// mustMarshalSSZ marshals v with its own generated (static, mainnet-preset) codec. +func mustMarshalSSZ(t *testing.T, v interface{ MarshalSSZ() ([]byte, error) }) []byte { + t.Helper() + + body, err := v.MarshalSSZ() + require.NoError(t, err) + + return body +} + +func TestBeaconStateFromSSZStaticCodec(t *testing.T) { + ctx := context.Background() + s := &Service{} + + tests := []struct { + name string + version spec.DataVersion + body []byte + }{ + { + name: "Phase0", + version: spec.DataVersionPhase0, + body: mustMarshalSSZ(t, &phase0.BeaconState{Slot: staticSlot}), + }, + { + name: "Altair", + version: spec.DataVersionAltair, + body: mustMarshalSSZ(t, &altair.BeaconState{Slot: staticSlot}), + }, + { + name: "Bellatrix", + version: spec.DataVersionBellatrix, + body: mustMarshalSSZ(t, &bellatrix.BeaconState{Slot: staticSlot}), + }, + { + name: "Capella", + version: spec.DataVersionCapella, + body: mustMarshalSSZ(t, &capella.BeaconState{Slot: staticSlot}), + }, + { + name: "Deneb", + version: spec.DataVersionDeneb, + body: mustMarshalSSZ(t, &deneb.BeaconState{Slot: staticSlot}), + }, + { + name: "Electra", + version: spec.DataVersionElectra, + body: mustMarshalSSZ(t, &electra.BeaconState{Slot: staticSlot}), + }, + { + name: "Fulu", + version: spec.DataVersionFulu, + body: mustMarshalSSZ(t, &fulu.BeaconState{Slot: staticSlot}), + }, + { + name: "Gloas", + version: spec.DataVersionGloas, + body: mustMarshalSSZ(t, &gloas.BeaconState{Slot: staticSlot}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res := &httpResponse{ + consensusVersion: test.version, + body: test.body, + } + + response, err := s.beaconStateFromSSZ(ctx, res) + require.NoError(t, err) + require.Equal(t, test.version, response.Data.Version) + + // Slot() reads through the arm the version names, so it fails both when the + // decode was skipped (slot 0) and when the body landed in another fork's arm. + slot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, staticSlot, slot) + }) + } +} + +func TestSignedBeaconBlockFromSSZStaticCodec(t *testing.T) { + ctx := context.Background() + s := &Service{} + + // There is no Gloas arm: under ePBS the block carries a signed execution payload + // bid rather than an inline payload, and that endpoint is not yet gloas-enabled. + tests := []struct { + name string + version spec.DataVersion + body []byte + }{ + { + name: "Phase0", + version: spec.DataVersionPhase0, + body: mustMarshalSSZ(t, &phase0.SignedBeaconBlock{Message: &phase0.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "Altair", + version: spec.DataVersionAltair, + body: mustMarshalSSZ(t, &altair.SignedBeaconBlock{Message: &altair.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "Bellatrix", + version: spec.DataVersionBellatrix, + body: mustMarshalSSZ(t, &bellatrix.SignedBeaconBlock{Message: &bellatrix.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "Capella", + version: spec.DataVersionCapella, + body: mustMarshalSSZ(t, &capella.SignedBeaconBlock{Message: &capella.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "Deneb", + version: spec.DataVersionDeneb, + body: mustMarshalSSZ(t, &deneb.SignedBeaconBlock{Message: &deneb.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "Electra", + version: spec.DataVersionElectra, + body: mustMarshalSSZ(t, &electra.SignedBeaconBlock{Message: &electra.BeaconBlock{Slot: staticSlot}}), + }, + { + // Fulu reuses electra's container, as VersionedSignedBeaconBlock.Fulu does. + name: "Fulu", + version: spec.DataVersionFulu, + body: mustMarshalSSZ(t, &electra.SignedBeaconBlock{Message: &electra.BeaconBlock{Slot: staticSlot}}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res := &httpResponse{ + consensusVersion: test.version, + body: test.body, + } + + response, err := s.signedBeaconBlockFromSSZ(ctx, res) + require.NoError(t, err) + require.Equal(t, test.version, response.Data.Version) + + slot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, staticSlot, slot) + }) + } +} + +func TestBeaconBlockProposalFromSSZStaticCodec(t *testing.T) { + ctx := context.Background() + s := &Service{} + + // From bellatrix onwards each version decodes a blinded or a full body, so both + // sides of that inner branch need a case of their own. + tests := []struct { + name string + version spec.DataVersion + blinded bool + body []byte + }{ + { + name: "Phase0", + version: spec.DataVersionPhase0, + body: mustMarshalSSZ(t, &phase0.BeaconBlock{Slot: staticSlot}), + }, + { + name: "Altair", + version: spec.DataVersionAltair, + body: mustMarshalSSZ(t, &altair.BeaconBlock{Slot: staticSlot}), + }, + { + name: "Bellatrix", + version: spec.DataVersionBellatrix, + body: mustMarshalSSZ(t, &bellatrix.BeaconBlock{Slot: staticSlot}), + }, + { + name: "BellatrixBlinded", + version: spec.DataVersionBellatrix, + blinded: true, + body: mustMarshalSSZ(t, &apiv1bellatrix.BlindedBeaconBlock{Slot: staticSlot}), + }, + { + name: "Capella", + version: spec.DataVersionCapella, + body: mustMarshalSSZ(t, &capella.BeaconBlock{Slot: staticSlot}), + }, + { + name: "CapellaBlinded", + version: spec.DataVersionCapella, + blinded: true, + body: mustMarshalSSZ(t, &apiv1capella.BlindedBeaconBlock{Slot: staticSlot}), + }, + { + name: "Deneb", + version: spec.DataVersionDeneb, + body: mustMarshalSSZ(t, &apiv1deneb.BlockContents{Block: &deneb.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "DenebBlinded", + version: spec.DataVersionDeneb, + blinded: true, + body: mustMarshalSSZ(t, &apiv1deneb.BlindedBeaconBlock{Slot: staticSlot}), + }, + { + name: "Electra", + version: spec.DataVersionElectra, + body: mustMarshalSSZ(t, &apiv1electra.BlockContents{Block: &electra.BeaconBlock{Slot: staticSlot}}), + }, + { + name: "ElectraBlinded", + version: spec.DataVersionElectra, + blinded: true, + body: mustMarshalSSZ(t, &apiv1electra.BlindedBeaconBlock{Slot: staticSlot}), + }, + { + name: "Fulu", + version: spec.DataVersionFulu, + body: mustMarshalSSZ(t, &apiv1fulu.BlockContents{Block: &electra.BeaconBlock{Slot: staticSlot}}), + }, + { + // The fulu blinded arm decodes electra's blinded block, as the decoder does. + name: "FuluBlinded", + version: spec.DataVersionFulu, + blinded: true, + body: mustMarshalSSZ(t, &apiv1electra.BlindedBeaconBlock{Slot: staticSlot}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // The decoder reads blindedness from this header, via + // populateProposalDataFromHeaders, before it picks an arm. + headers := map[string]string{} + if test.blinded { + headers["Eth-Execution-Payload-Blinded"] = "true" + } + + res := &httpResponse{ + consensusVersion: test.version, + headers: headers, + body: test.body, + } + + response, err := s.beaconBlockProposalFromSSZ(ctx, res) + require.NoError(t, err) + require.Equal(t, test.version, response.Data.Version) + require.Equal(t, test.blinded, response.Data.Blinded) + + slot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, staticSlot, slot) + }) + } +} + +// staticBlob returns a blob whose first and last bytes are set, so that a decoded blob +// can be told apart from the 128KiB of zeroes a freshly allocated one holds. +func staticBlob() *deneb.Blob { + blob := new(deneb.Blob) + blob[0] = 0xab + blob[len(blob)-1] = 0xcd + + return blob +} + +func TestBlobsFromSSZStaticCodec(t *testing.T) { + ctx := context.Background() + s := &Service{} + + t.Run("Blobs", func(t *testing.T) { + blobs := apiv1.Blobs{staticBlob()} + res := &httpResponse{ + consensusVersion: spec.DataVersionFulu, + body: mustMarshalSSZ(t, &blobs), + } + + response, err := s.blobsFromSSZ(ctx, res) + require.NoError(t, err) + require.Len(t, response.Data, 1) + require.Equal(t, byte(0xab), response.Data[0][0]) + require.Equal(t, byte(0xcd), response.Data[0][len(response.Data[0])-1]) + }) + + // An empty body is a valid "no blobs for this block" response, and returns before + // the decoder reaches either branch. + t.Run("EmptyBody", func(t *testing.T) { + res := &httpResponse{consensusVersion: spec.DataVersionFulu} + + response, err := s.blobsFromSSZ(ctx, res) + require.NoError(t, err) + require.Empty(t, response.Data) + }) +} + +func TestBlobSidecarsFromSSZStaticCodec(t *testing.T) { + ctx := context.Background() + s := &Service{} + + t.Run("BlobSidecars", func(t *testing.T) { + sidecars := &api.BlobSidecars{ + Sidecars: []*deneb.BlobSidecar{{Index: 3, Blob: *staticBlob()}}, + } + res := &httpResponse{ + consensusVersion: spec.DataVersionDeneb, + body: mustMarshalSSZ(t, sidecars), + } + + response, err := s.blobSidecarsFromSSZ(ctx, res) + require.NoError(t, err) + require.Len(t, response.Data, 1) + require.Equal(t, deneb.BlobIndex(3), response.Data[0].Index) + require.Equal(t, byte(0xab), response.Data[0].Blob[0]) + }) + + t.Run("EmptyBody", func(t *testing.T) { + res := &httpResponse{consensusVersion: spec.DataVersionDeneb} + + response, err := s.blobSidecarsFromSSZ(ctx, res) + require.NoError(t, err) + require.Empty(t, response.Data) + }) +} diff --git a/http/staticsszdecode_test.go b/http/staticsszdecode_test.go new file mode 100644 index 000000000..9ba9c8067 --- /dev/null +++ b/http/staticsszdecode_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_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/stretchr/testify/require" +) + +// TestBeaconStateSSZSpecModes fetches the same SSZ beacon state through both of the +// suite's spec modes, and pins what separates them. +// +// The custom-spec service decodes against the spec the node reports, so it must succeed +// whatever preset that node runs. The default-spec service takes the static generated +// codecs instead, which are built at the mainnet preset: it must succeed against a +// mainnet-preset node and must fail against any other. That failure is the reason this +// suite opts in to custom spec support at all (see ADR-0003), so it is asserted rather +// than skipped over. +// +// The beacon state is the only endpoint used here because it is the one SSZ endpoint the +// interim Glamsterdam devnet serves for both modes: the block endpoints have no gloas arm +// yet, and the devnet's head block carries no blobs. The static branch of every SSZ +// decoder, including those, is covered without a node in +// staticsszdecode_internal_test.go. +func TestBeaconStateSSZSpecModes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + customSpecService := testService(ctx, t).(client.Service) + + // Only testService takes a coordinator token, which is per test, not per service. + defaultSpecService, err := newTestService(ctx, false) + require.NoError(t, err) + + specs, err := customSpecService.(client.SpecProvider).Spec(ctx, &api.SpecOpts{}) + require.NoError(t, err) + + presetBase, isString := specs.Data["PRESET_BASE"].(string) + require.True(t, isString, "spec has no PRESET_BASE") + + opts := &api.BeaconStateOpts{State: "head"} + + t.Run("CustomSpec", func(t *testing.T) { + response, err := customSpecService.(client.BeaconStateProvider).BeaconState(ctx, opts) + require.NoError(t, err) + require.NotNil(t, response.Data) + }) + + t.Run("DefaultSpec", func(t *testing.T) { + response, err := defaultSpecService.(client.BeaconStateProvider).BeaconState(ctx, opts) + if presetBase == "mainnet" { + require.NoError(t, err) + require.NotNil(t, response.Data) + + return + } + + // Any other preset sizes its containers differently from the generated codecs, + // so the static decode must reject the body rather than mis-read it. + require.ErrorContains(t, err, "failed to decode") + }) +} From 5145a114c1210bab7e1efbfe6076952fe5a9d676 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 28 Jul 2026 15:52:26 +0200 Subject: [PATCH 12/37] bounds-check the DataVersion string table on both of its readers DataVersion.MarshalJSON indexed the fixed-size dataVersionStrings array directly with no bounds check, where String() guarded the identical access and returned "unknown". A version past the end of the table therefore panicked with index out of range during marshal, aborting the marshal of the whole enclosing response object. encoding/json re-panics anything that is not its own sentinel, so the caller cannot recover it. The normal decode path cannot produce such a value -- UnmarshalJSON is a closed map lookup that rejects unrecognised strings. It needs a DataVersion built directly: a caller casting an int, a fork constant added to the enum and not to the table, or an SSZ union decoder copying an unvalidated wire field into the enum. MarshalJSON now takes its string from String(), so dataVersionStrings is indexed at exactly one site and the two readers cannot diverge. That alone was not enough, and finding out why was the substance of this change. String()'s own guard read `int(d) >= len(dataVersionStrings)`, but DataVersion is uint64: for any value at or above 1<<63 the conversion to signed int wraps negative, the comparison is false, and the array is then indexed with the unconverted uint64. Delegating to String() would have closed 9 through 2^63-1 and left the top half of the domain panicking -- from String() as well as from MarshalJSON. Reproduced before fixing: DataVersion(1<<63) panicked with "index out of range [9223372036854775808] with length 9" from both. The guard now compares in uint64, matching the type being bounded and the form the sibling BuilderVersion.String() already used; dataversion.go was the anomaly in its own package. Key decisions: - Both defects are one fix, not two. The acceptance criterion is that marshalling an out-of-range version returns a value rather than panicking, and 1<<63 is out of range, so stopping at the delegation would have left the criterion unmet while looking met. - The test marshals &test.version, not test.version, and says why. MarshalJSON has a pointer receiver, so encoding/json only invokes it for an addressable value; json.Marshal on a bare DataVersion silently bypasses the marshaler and emits the underlying number. A test written the obvious way would have passed against the unfixed code and proven nothing. - Expected values are pinned literals rather than String() output. Once MarshalJSON is implemented in terms of String(), comparing the two holds however either drifts. The literals also lock the enum's iota block and the string table together: insert a fork mid-enum without extending the table and every later row shifts and fails, which is the divergence class this change exists for. - One table with the out-of-range values as ordinary rows, following api/v1/validatorstate_test.go for the same bug class. require.NotPanics is kept deliberately: an unwrapped panic aborts the whole test binary, so siblings never report, while wrapped it fails exactly one subtest. - Scope held to DataVersion. The structurally identical unguarded index in BuilderVersion.MarshalJSON, and the SSZ decoders that make these panics remotely reachable, are tracked separately rather than folded in. Files changed: - modified: spec/version/dataversion.go (MarshalJSON delegates to String(); String()'s guard compares in uint64; 2 lines changed, 4 comment lines added) - new: spec/version/dataversion_test.go (12 rows; the first test file in this package) Verified: go build ./... clean; gosilent test ./... 2273 green, up from 2261; -race clean; custom-gcl 0 issues repo-wide; gofmt and go vet clean on both files -- .golangci.yml sets run.tests: false, so lint never inspects the test file and vet is what covers it. The SSZ codegen and consensus-spec vector gates do not apply: nothing under spec/gloas was touched. Coverage is mutation-proven, not assumed. Three mutations, each failing exactly its own rows and no others: reverting MarshalJSON to the direct index fails the three out-of-range rows while all nine valid-version rows still pass; reverting the guard to int(d) fails only SignedRangeBoundary and MaxUint64; corrupting one string-table entry fails only that row. The first of those is the evidence that the wire format is unchanged -- the valid-version output is byte-identical with and without the fix. Blockers / notes for next iteration: - BuilderVersion.MarshalJSON carries the identical unguarded index and is worse: responseBuilderVersionStrings has length 1, so it panics for any non-zero value, no extreme input needed. Its String() already has the correct uint64 guard, so the fix is the same one-line delegation. It is reachable from untrusted relay registration traffic via the validator-registration SSZ decoder, and is worth prioritising. - Three generated SSZ union decoders copy an unvalidated 8-byte discriminant straight into the version enum and return nil. Every offset in those decoders is range-checked; the discriminant is not. DataVersion is now closed against anything they can produce, so this is no longer a panic vector through it, but it still is through BuilderVersion, and a decoded union naming no arm is a state no constructor can produce. These are generated files, so any fix belongs in the generator config or in hand-written validation beside them. - Pre-existing and deliberately untouched: fmt.Appendf(nil, "%q", ...) measured ~6x slower than a manual quote-wrap with one extra allocation, unchanged by this commit and called once per response object rather than per sub-item. - dataVersionStrings, the iota block and dataVersionMap are three hand- maintained parallel lists of the same fork names. The pinned rows now lock the first two together; the map is guarded by nothing. --- spec/version/dataversion.go | 8 +++- spec/version/dataversion_test.go | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 spec/version/dataversion_test.go diff --git a/spec/version/dataversion.go b/spec/version/dataversion.go index f1ecf9114..42cafb1e2 100644 --- a/spec/version/dataversion.go +++ b/spec/version/dataversion.go @@ -66,8 +66,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. @@ -86,7 +88,9 @@ func (d *DataVersion) UnmarshalJSON(input []byte) error { // String returns a string representation of the struct. func (d DataVersion) String() string { - if int(d) >= len(dataVersionStrings) { + // uint64, not int: int(d) wraps negative at 1<<63 and would slip past into + // an out-of-range index. + if uint64(d) >= uint64(len(dataVersionStrings)) { return "unknown" } diff --git a/spec/version/dataversion_test.go b/spec/version/dataversion_test.go new file mode 100644 index 000000000..5a27f2485 --- /dev/null +++ b/spec/version/dataversion_test.go @@ -0,0 +1,63 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version_test + +import ( + "encoding/json" + "math" + "testing" + + "github.com/attestantio/go-eth2-client/spec/version" + "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 version.DataVersion + expected string + }{ + {name: "Unknown", version: version.DataVersionUnknown, expected: `"unknown"`}, + {name: "Phase0", version: version.DataVersionPhase0, expected: `"phase0"`}, + {name: "Altair", version: version.DataVersionAltair, expected: `"altair"`}, + {name: "Bellatrix", version: version.DataVersionBellatrix, expected: `"bellatrix"`}, + {name: "Capella", version: version.DataVersionCapella, expected: `"capella"`}, + {name: "Deneb", version: version.DataVersionDeneb, expected: `"deneb"`}, + {name: "Electra", version: version.DataVersionElectra, expected: `"electra"`}, + {name: "Fulu", version: version.DataVersionFulu, expected: `"fulu"`}, + {name: "Gloas", version: version.DataVersionGloas, expected: `"gloas"`}, + // A fork added to the enum but not to the string table lands here. + {name: "PastLastVersion", version: version.DataVersionGloas + 1, expected: `"unknown"`}, + // A bounds check written against int, not uint64, wraps negative here. + {name: "SignedRangeBoundary", version: 1 << 63, expected: `"unknown"`}, + {name: "MaxUint64", version: math.MaxUint64, expected: `"unknown"`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { + res, err := json.Marshal(&test.version) + require.NoError(t, err) + require.Equal(t, test.expected, string(res)) + }) + }) + } +} From 6422b9f7b2c507057c565f0eaf0fd8b9f78e4a23 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 29 Jul 2026 12:02:11 +0200 Subject: [PATCH 13/37] add the PTC validator flow: duties, data, pool and submission The observational half of the payload timeliness committee surface already existed on this branch -- the four PayloadAttestation* containers, gloas.BeaconState.PTCWindow, and the payload_attestation_message SSE topic -- so a validator could watch PTC activity but had no way to take part in it. The four endpoints that make it participatory were missing entirely: duties, data production, pool retrieval, and submission. None of this is a port. The ethpandaops fork has no PTC endpoints on any branch, so every wire-format detail here was read from the beacon-APIs spec directly and then checked against two running clients rather than pattern-matched from an adjacent endpoint. That turned up three things the endpoint table alone would not have given: - producePayloadAttestationData defines a 204 meaning "no block has been seen for this slot; do not attest". Nothing in this package inspected a status code before, and s.get returns a 204 with no error and no content type, so the existing shape would have surfaced it as "unhandled content type unknown". - submitPayloadAttestationMessages requires an Eth-Consensus-Version header. Prysm refuses a headerless request before it looks at the body. - Lighthouse does not implement the pool GET at all; prysm implements all four. The pool GET is the only one of the four with no Lighthouse route. Key decisions: - A 204 returns the new sentinel ErrNoPayloadAttestationData rather than an empty-but-valid response. The response form is the tempting one, since the wrapper already has IsEmpty(), but it hands a caller who forgets to check a zero-valued PayloadAttestationData that its accessors report without error -- a signable vote for the zero root. This is the fail-open class the hallucination log keeps recording, so it fails closed instead, in the shape sql.ErrNoRows uses for the same reason: a normal outcome, modelled as a sentinel precisely so it cannot be mistaken for data. - Each endpoint splits its network fetch from its response decode. This is not layering for its own sake: it is the only way the two most interesting paths of producePayloadAttestationData get covered at all. A node produces data only for its current slot, so a 204 cannot be provoked on demand, and neither client serves this endpoint as SSZ. Both are exercised in-process against a constructed httpResponse. - PayloadAttestationData decodes SSZ with the static generated codec, not the dynssz codec every other Gloas endpoint uses. The container is a root, a slot and two bools with no dynssz-* tags, so it is preset-independent and the static codec is correct at any preset -- which matters here because the devnet runs minimal. Benchmarked: 13ns/1 alloc against 6,700ns/114 allocs for the dynssz path under WithCustomSpecSupport, which rebuilds its type cache per call. - Three wrappers, not the two the versioned responses need. The third wraps PayloadAttestationMessage so the required consensus-version header is derived from the caller's data instead of hard-coded "gloas", matching both existing submitters. The durable reason is not the header though: Messages is a shipped exported field, and typing it []*gloas.PayloadAttestationMessage would make it the only fork-typed submitter opts in the repo and a breaking change at the next fork. - The wrappers' accessors funnel through one private helper and collapse the seven pre-Gloas versions into a single arm that interpolates the version. The precedent spells out seven arms per accessor with hard-coded messages; the text produced is byte-identical and the drift between accessors that the older wrappers already show becomes impossible. - providerparity_test.go makes the multi/mock/testclients parity a build error. Nothing else in this repo enforces it -- implementations are reached by runtime type assertion, so a missing method compiles cleanly and fails only where a caller asserts. It holds declarations and no test function, which was verified to still fail the build: renaming mock's PTCDuties breaks `go test ./...` outright. - PTCDuties builds its request body with json.Marshal over a []string rather than the hand-rolled fmt.Fprintf loop its analogue uses. Same bytes, and it avoids adding a fifth copy of a loop that already has tracked de-duplication work against it. Measured, it is also the faster of the two. - Mock override hooks follow each endpoint's named analogue rather than being applied uniformly: PTCDuties and PayloadAttestationData get them, matching AttesterDuties and AttestationData; the pool and the submitter do not, matching theirs. Two defects were fixed during review, both the same fail-open shape: - The JSON arm seeded its decoder with an empty datum, and decodeJSONResponse only writes the keys it finds, so a body of {"version":"gloas"} with no data key at all returned success wrapping a zero datum. It now seeds a typed nil pointer, which makes an absent or null data key distinguishable from a present one. Probed afterwards: a present-but-partial data object is rejected too, by the container's own required-field decoding. - verifyPayloadAttestationPool early-returned when no slot filter was given, which skipped the element loop entirely, so [null,null] came back unvalidated. It now always reads each element and filters by slot only when a slot was asked for. A null element cannot arrive as a nil slice element -- the wrap loop always allocates -- so this errors rather than panicking, which was verified rather than assumed. Files changed: - new: api/v1/ptcduty.go (+test) - new: api/{ptcduties,payloadattestationdata,payloadattestationpool,submitpayloadattestationmessages}opts.go - new: spec/versionedpayloadattestation{data,,message}.go (+3 tests) - new: http/{ptcduties,payloadattestationdata,payloadattestationpool,submitpayloadattestationmessages}.go (+4 external tests, +4 internal tests) - new: multi/ and mock/ wrappers for all four - new: providerparity_test.go - modified: service.go (4 provider interfaces), errors.go (1 sentinel), mock/service.go (2 hook fields), testclients/{erroring,sleepy}.go (4 methods each) 38 files, 2971 insertions, 0 deletions. No existing file under http/ was modified, which is what makes a regression in the existing suite structurally impossible rather than merely unobserved. Verified: go build ./... clean; gosilent test ./... 2306 green, up from 2273; custom-gcl 0 issues repo-wide; go vet and gofmt clean on all 38 files; go generate ./spec/gloas byte-identical. Against a live Glamsterdam devnet-7 prysm node (minimal preset) all 40 new subtests pass, also under -race. Lighthouse was not usable as the target: it reports is_syncing true, so every call fails the synced assertion. Note .golangci.yml sets run.tests: false, so lint never inspects the test files -- go vet and gofmt are what cover them. Coverage is mutation-proven. Eight mutations, each failing exactly its own subtest and no others: neutering the 204 guard; renaming the consensus-version header, which failed the submit test on precisely the assertion written to detect it; removing the nil-arm guard that would put [null] in a request body; transposing the two boolean accessors; wrapping every pool element with the first element's attestation; removing the slots-per-epoch zero guard; and, after the review trims, re-running the boolean transposition and the parity assertion. The boolean one is the instructive case: the fixture originally set both flags true, so the transposition was invisible to every accessor test and only a dedicated test caught it. The fixture now sets them apart, which lets the ordinary subtest catch it and the dedicated test be deleted. Five reviewers ran. Spec independently confirmed the preset-independence claim by reading the generated codec, and re-ran everything live. Efficiency benchmarked the static-versus-dynamic SSZ decision. Security probed the decoders with hostile bodies through a test overlay. After one fix round all five approve. Blockers / notes for next iteration: - The success path of producePayloadAttestationData has no live coverage. A node serves it only for its current slot, and this devnet's chain has stalled: head slot is around 59450 against a wall-clock slot around 71500, so lighthouse answers 400 "Invalid slot" for every slot and prysm returns nothing at the current one. A healthy devnet would close this; it is the one acceptance criterion not verified end to end. - The same stall makes wall-clock-derived epochs unusable: asking prysm for duties at the wall-clock epoch hangs the request indefinitely, which is why the new tests derive epoch and slot from the node's head. That is also correct on a healthy chain, where the two coincide. It is worth knowing that the pre-existing TestAttesterDuties fails here for exactly this reason and not for anything this change did. - No http/ test can gate CI, since TestMain returns without calling m.Run() unless HTTP_ADDRESS is set. Everything added here under http/ is therefore invisible to ordinary CI; the api/v1, spec/ and root-package tests, which include the parity assertions, are not. Tracked separately. - The pool GET is prysm-only today. Anything downstream that needs it should not assume a Lighthouse node can serve it. - Two pre-existing defects found during review and captured as separate follow-up work, both fork-independent rather than Gloas-specific. The epoch-range check on duties now exists in three copies and only the new one guards a node reporting SLOTS_PER_EPOCH of 0, where the other two underflow the range end to MaxUint64 and accept everything, or nil-deref on a null duty. And thirteen http endpoints pass a fresh &api.CommonOpts{} instead of &opts.Common, silently discarding the caller's per-call timeout; the zero value means "use the default", so the bug is invisible. - Unchecked aggregation_bits length in the gloas payload-attestation JSON decoder is real but pre-existing and already tracked; it cannot panic, because go-bitfield guards the vector length. --- api/payloadattestationdataopts.go | 24 +++ api/payloadattestationpoolopts.go | 26 +++ api/ptcdutiesopts.go | 26 +++ api/submitpayloadattestationmessagesopts.go | 26 +++ api/v1/ptcduty.go | 115 ++++++++++++ api/v1/ptcduty_test.go | 140 +++++++++++++++ errors.go | 5 + http/payloadattestationdata.go | 146 +++++++++++++++ http/payloadattestationdata_internal_test.go | 158 ++++++++++++++++ http/payloadattestationdata_test.go | 57 ++++++ http/payloadattestationpool.go | 136 ++++++++++++++ http/payloadattestationpool_internal_test.go | 168 ++++++++++++++++++ http/payloadattestationpool_test.go | 56 ++++++ http/ptcduties.go | 118 ++++++++++++ http/ptcduties_internal_test.go | 94 ++++++++++ http/ptcduties_test.go | 109 ++++++++++++ http/submitpayloadattestationmessages.go | 125 +++++++++++++ ...ayloadattestationmessages_internal_test.go | 118 ++++++++++++ http/submitpayloadattestationmessages_test.go | 90 ++++++++++ mock/payloadattestationdata.go | 46 +++++ mock/payloadattestationpool.go | 57 ++++++ mock/ptcduties.go | 47 +++++ mock/service.go | 2 + mock/submitpayloadattestationmessages.go | 27 +++ multi/payloadattestationdata.go | 49 +++++ multi/payloadattestationpool.go | 49 +++++ multi/ptcduties.go | 49 +++++ multi/submitpayloadattestationmessages.go | 37 ++++ providerparity_test.go | 57 ++++++ service.go | 48 +++++ spec/versionedpayloadattestation.go | 109 ++++++++++++ spec/versionedpayloadattestation_test.go | 99 +++++++++++ spec/versionedpayloadattestationdata.go | 112 ++++++++++++ spec/versionedpayloadattestationdata_test.go | 108 +++++++++++ spec/versionedpayloadattestationmessage.go | 107 +++++++++++ ...versionedpayloadattestationmessage_test.go | 93 ++++++++++ testclients/erroring.go | 73 ++++++++ testclients/sleepy.go | 65 +++++++ 38 files changed, 2971 insertions(+) create mode 100644 api/payloadattestationdataopts.go create mode 100644 api/payloadattestationpoolopts.go create mode 100644 api/ptcdutiesopts.go create mode 100644 api/submitpayloadattestationmessagesopts.go create mode 100644 api/v1/ptcduty.go create mode 100644 api/v1/ptcduty_test.go create mode 100644 http/payloadattestationdata.go create mode 100644 http/payloadattestationdata_internal_test.go create mode 100644 http/payloadattestationdata_test.go create mode 100644 http/payloadattestationpool.go create mode 100644 http/payloadattestationpool_internal_test.go create mode 100644 http/payloadattestationpool_test.go create mode 100644 http/ptcduties.go create mode 100644 http/ptcduties_internal_test.go create mode 100644 http/ptcduties_test.go create mode 100644 http/submitpayloadattestationmessages.go create mode 100644 http/submitpayloadattestationmessages_internal_test.go create mode 100644 http/submitpayloadattestationmessages_test.go create mode 100644 mock/payloadattestationdata.go create mode 100644 mock/payloadattestationpool.go create mode 100644 mock/ptcduties.go create mode 100644 mock/submitpayloadattestationmessages.go create mode 100644 multi/payloadattestationdata.go create mode 100644 multi/payloadattestationpool.go create mode 100644 multi/ptcduties.go create mode 100644 multi/submitpayloadattestationmessages.go create mode 100644 providerparity_test.go create mode 100644 spec/versionedpayloadattestation.go create mode 100644 spec/versionedpayloadattestation_test.go create mode 100644 spec/versionedpayloadattestationdata.go create mode 100644 spec/versionedpayloadattestationdata_test.go create mode 100644 spec/versionedpayloadattestationmessage.go create mode 100644 spec/versionedpayloadattestationmessage_test.go diff --git a/api/payloadattestationdataopts.go b/api/payloadattestationdataopts.go new file mode 100644 index 000000000..bccefa51e --- /dev/null +++ b/api/payloadattestationdataopts.go @@ -0,0 +1,24 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PayloadAttestationDataOpts are the options for obtaining payload attestation data. +type PayloadAttestationDataOpts struct { + Common CommonOpts + + // Slot is the slot for which the data is obtained. + Slot phase0.Slot +} diff --git a/api/payloadattestationpoolopts.go b/api/payloadattestationpoolopts.go new file mode 100644 index 000000000..6c0f5a618 --- /dev/null +++ b/api/payloadattestationpoolopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PayloadAttestationPoolOpts are the options for obtaining the payload attestation pool. +type PayloadAttestationPoolOpts struct { + Common CommonOpts + + // Slot is the slot for which the data is obtained. If not present then + // data for all slots will be obtained. Note that some beacon nodes + // require a slot and will reject a request without one. + Slot *phase0.Slot +} diff --git a/api/ptcdutiesopts.go b/api/ptcdutiesopts.go new file mode 100644 index 000000000..f118e30e1 --- /dev/null +++ b/api/ptcdutiesopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PTCDutiesOpts are the options for obtaining payload timeliness committee duties. +type PTCDutiesOpts struct { + Common CommonOpts + + // Epoch is the epoch for which the data is obtained. + Epoch phase0.Epoch + // Indices is a list of validators for which to obtain the duties. + Indices []phase0.ValidatorIndex +} diff --git a/api/submitpayloadattestationmessagesopts.go b/api/submitpayloadattestationmessagesopts.go new file mode 100644 index 000000000..59cfa9224 --- /dev/null +++ b/api/submitpayloadattestationmessagesopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec" + +// SubmitPayloadAttestationMessagesOpts are the options for submitting payload +// attestation messages. +type SubmitPayloadAttestationMessagesOpts struct { + Common CommonOpts + + // Messages are the payload attestation messages to submit. They must all + // be of the same version. + Messages []*spec.VersionedPayloadAttestationMessage +} diff --git a/api/v1/ptcduty.go b/api/v1/ptcduty.go new file mode 100644 index 000000000..5b1c657b2 --- /dev/null +++ b/api/v1/ptcduty.go @@ -0,0 +1,115 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// PTCDuty is the data regarding which validators have the duty to attest to +// payload timeliness in a slot. +// +// The payload timeliness committee is a flat, per-slot committee, so unlike +// AttesterDuty this carries no committee index, committee length or +// validator-committee index. +type PTCDuty struct { + // PubKey is the public key of the validator that should attest. + PubKey phase0.BLSPubKey + // Slot is the slot in which the validator should attest. + Slot phase0.Slot + // ValidatorIndex is the index of the validator that should attest. + ValidatorIndex phase0.ValidatorIndex +} + +// ptcDutyJSON is the spec representation of the struct. +type ptcDutyJSON struct { + PubKey string `json:"pubkey"` + ValidatorIndex string `json:"validator_index"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PTCDuty) MarshalJSON() ([]byte, error) { + return json.Marshal(&ptcDutyJSON{ + PubKey: fmt.Sprintf("%#x", p.PubKey), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Slot: fmt.Sprintf("%d", p.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PTCDuty) UnmarshalJSON(input []byte) error { + var err error + + var ptcDutyJSON ptcDutyJSON + if err = json.Unmarshal(input, &ptcDutyJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if ptcDutyJSON.PubKey == "" { + return errors.New("public key missing") + } + + pubKey, err := hex.DecodeString(strings.TrimPrefix(ptcDutyJSON.PubKey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(pubKey) != publicKeyLength { + return errors.New("incorrect length for public key") + } + + copy(p.PubKey[:], pubKey) + + if ptcDutyJSON.ValidatorIndex == "" { + return errors.New("validator index missing") + } + + validatorIndex, err := strconv.ParseUint(ptcDutyJSON.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for validator index") + } + + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + if ptcDutyJSON.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(ptcDutyJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + p.Slot = phase0.Slot(slot) + + return nil +} + +// String returns a string version of the structure. +func (p *PTCDuty) String() string { + data, err := json.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/ptcduty_test.go b/api/v1/ptcduty_test.go new file mode 100644 index 000000000..6a1fd15c9 --- /dev/null +++ b/api/v1/ptcduty_test.go @@ -0,0 +1,140 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + require "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" +) + +// TestPTCDutyJSON exercises the PTC duty codec. +// +// The Good case is a duty captured verbatim from a live Glamsterdam devnet-7 +// Lighthouse node (POST /eth/v1/validator/duties/ptc/7537), so the field set +// and the string-encoded integers are the real wire shape rather than an +// inference from the schema. +func TestPTCDutyJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.ptcDutyJSON", + }, + { + name: "PubKeyMissing", + input: []byte(`{"validator_index":"8","slot":"60300"}`), + err: "public key missing", + }, + { + name: "PubKeyWrongType", + input: []byte(`{"pubkey":true,"validator_index":"8","slot":"60300"}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field ptcDutyJSON.pubkey of type string", + }, + { + name: "PubKeyInvalid", + input: []byte(`{"pubkey":"invalid","validator_index":"8","slot":"60300"}`), + err: "invalid value for public key: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "PubKeyShort", + input: []byte(`{"pubkey":"0x2cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + err: "incorrect length for public key", + }, + { + name: "PubKeyLong", + input: []byte(`{"pubkey":"0xb7b72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + err: "incorrect length for public key", + }, + { + name: "ValidatorIndexMissing", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","slot":"60300"}`), + err: "validator index missing", + }, + { + name: "ValidatorIndexWrongType", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":8,"slot":"60300"}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field ptcDutyJSON.validator_index of type string", + }, + { + name: "ValidatorIndexInvalid", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"-1","slot":"60300"}`), + err: "invalid value for validator index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "SlotMissing", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8"}`), + err: "slot missing", + }, + { + name: "SlotWrongType", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":true}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field ptcDutyJSON.slot of type string", + }, + { + name: "SlotInvalid", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"-1"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Good", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.PTCDuty + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } +} + +// TestPTCDutyFields confirms the decoded values land in the right fields, and +// that a PTC duty carries no committee information. A round-trip test alone +// would pass even if two same-typed fields were transposed. +func TestPTCDutyFields(t *testing.T) { + input := []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`) + + var duty api.PTCDuty + require.NoError(t, json.Unmarshal(input, &duty)) + + require.Equal(t, + "0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360", + duty.PubKey.String(), + ) + require.Equal(t, uint64(8), uint64(duty.ValidatorIndex)) + require.Equal(t, uint64(60300), uint64(duty.Slot)) +} diff --git a/errors.go b/errors.go index 8cb6bb1fe..8b1da982a 100644 --- a/errors.go +++ b/errors.go @@ -26,4 +26,9 @@ 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") + // ErrNoPayloadAttestationData is returned when the node has seen no block for the requested + // slot, and so the validator must not cast a payload attestation for it. This is a normal + // outcome rather than a failure, but it is reported as an error so that a caller cannot + // mistake it for a datum that is safe to sign. + ErrNoPayloadAttestationData = errors.New("no payload attestation data available") ) diff --git a/http/payloadattestationdata.go b/http/payloadattestationdata.go new file mode 100644 index 000000000..7d339a032 --- /dev/null +++ b/http/payloadattestationdata.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 http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +// +// A node only produces this for its current slot; if it has seen no block for +// that slot it answers 204, which surfaces as client.ErrNoPayloadAttestationData +// and means the validator must not cast a payload attestation. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + endpoint := "/eth/v1/validator/payload_attestation_data" + query := fmt.Sprintf("slot=%d", opts.Slot) + + httpResponse, err := s.get(ctx, endpoint, query, &opts.Common, true) + 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 000000000..973360a84 --- /dev/null +++ b/http/payloadattestationdata_internal_test.go @@ -0,0 +1,158 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/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") + }) + + // An explicit null decodes to the same nil pointer as an absent key, so + // the same check catches it. It is pinned separately because it used to + // take a different route: the arm came back nil and only the endpoint's + // own Slot() call downstream refused it, which left this function + // reporting success on a response carrying nothing to attest to. + 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") + }) +} diff --git a/http/payloadattestationdata_test.go b/http/payloadattestationdata_test.go new file mode 100644 index 000000000..d51a0aa8a --- /dev/null +++ b/http/payloadattestationdata_test.go @@ -0,0 +1,57 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "errors" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/stretchr/testify/require" +) + +// TestPayloadAttestationData exercises the endpoint against a live node. +// +// A beacon node only produces payload attestation data for its current slot, +// so the success path is not reachable on demand; it is covered in-process by +// TestPayloadAttestationDataFromResponse, which also covers the SSZ body and +// the 204 "no block seen" signal that a node will not emit to order. What is +// verified here is that a structurally-valid request reaches the server and +// that its rejection surfaces as a typed api.Error rather than a decode +// failure. +func TestPayloadAttestationData(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + t.Run("NilOpts", func(t *testing.T) { + _, err := service.(client.PayloadAttestationDataProvider).PayloadAttestationData(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("PastSlot", func(t *testing.T) { + // Slot 1 is long past, so the node must refuse to produce data for it. + _, err := service.(client.PayloadAttestationDataProvider).PayloadAttestationData(ctx, + &api.PayloadAttestationDataOpts{Slot: 1}, + ) + require.Error(t, err) + + var apiErr *api.Error + require.True(t, errors.As(err, &apiErr), "expected a typed api.Error, got %v", err) + require.Equal(t, 400, apiErr.StatusCode) + }) +} diff --git a/http/payloadattestationpool.go b/http/payloadattestationpool.go new file mode 100644 index 000000000..67daedebc --- /dev/null +++ b/http/payloadattestationpool.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 http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (s *Service) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + endpoint := "/eth/v1/beacon/pool/payload_attestations" + + query := "" + if opts.Slot != nil { + query = fmt.Sprintf("slot=%d", *opts.Slot) + } + + // The endpoint advertises SSZ, but as a bare SSZ list with no container to + // decode into, so JSON is requested here. + httpResponse, err := s.get(ctx, endpoint, query, &opts.Common, false) + 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 { + 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 000000000..0098d7c07 --- /dev/null +++ b/http/payloadattestationpool_internal_test.go @@ -0,0 +1,168 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// poolAttestationJSON builds one wire-format payload attestation for the given +// slot. Aggregation bits are a 64-byte bitvector and the signature 96 bytes. +func poolAttestationJSON(slot phase0.Slot) string { + return fmt.Sprintf( + `{"aggregation_bits":"0x08%s","data":{"beacon_block_root":"0x0102030000000000000000000000000000000000000000000000000000000000","slot":"%d","payload_present":true,"blob_data_available":true},"signature":"0xaa%s"}`, + strings.Repeat("00", 63), + slot, + strings.Repeat("00", 95), + ) +} + +func TestPayloadAttestationPoolFromResponse(t *testing.T) { + body := func(attestations ...string) []byte { + return []byte(`{"version":"gloas","data":[` + strings.Join(attestations, ",") + `]}`) + } + + t.Run("JSON", func(t *testing.T) { + response, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body(poolAttestationJSON(60300), poolAttestationJSON(60301)), + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Len(t, response.Data, 2) + + for i, expectedSlot := range []phase0.Slot{60300, 60301} { + require.Equal(t, spec.DataVersionGloas, response.Data[i].Version) + + data, err := response.Data[i].Data() + require.NoError(t, err) + require.Equal(t, expectedSlot, data.Slot) + + // Every element must carry the version, not just the first: a loop + // that set it once outside the body would still pass a length check. + aggregationBits, err := response.Data[i].AggregationBits() + require.NoError(t, err) + require.True(t, aggregationBits.BitAt(3)) + } + }) + + 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") + }) +} + +// 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/payloadattestationpool_test.go b/http/payloadattestationpool_test.go new file mode 100644 index 000000000..6416a4e37 --- /dev/null +++ b/http/payloadattestationpool_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_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestPayloadAttestationPool(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + slot := headSlot(ctx, t, service) + + t.Run("NilOpts", func(t *testing.T) { + _, err := service.(client.PayloadAttestationPoolProvider).PayloadAttestationPool(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("Good", func(t *testing.T) { + response, err := service.(client.PayloadAttestationPoolProvider).PayloadAttestationPool(ctx, + &api.PayloadAttestationPoolOpts{Slot: &slot}, + ) + require.NoError(t, err) + require.NotNil(t, response) + + // The pool for a historical slot is usually empty; whatever it holds + // must be a gloas attestation for the slot that was asked for. + for _, attestation := range response.Data { + require.Equal(t, spec.DataVersionGloas, attestation.Version) + + data, err := attestation.Data() + require.NoError(t, err) + require.Equal(t, slot, data.Slot) + } + }) +} diff --git a/http/ptcduties.go b/http/ptcduties.go new file mode 100644 index 000000000..353861d92 --- /dev/null +++ b/http/ptcduties.go @@ -0,0 +1,118 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + if len(opts.Indices) == 0 { + return nil, errors.Join(errors.New("no validator indices specified"), client.ErrInvalidOptions) + } + + // The endpoint takes an array of validator indices, which the beacon API + // encodes as quoted decimal strings. + indices := make([]string, len(opts.Indices)) + for i := range opts.Indices { + indices[i] = strconv.FormatUint(uint64(opts.Indices[i]), 10) + } + + reqBody, err := json.Marshal(indices) + if err != nil { + return nil, errors.Join(errors.New("failed to marshal validator indices"), err) + } + + endpoint := fmt.Sprintf("/eth/v1/validator/duties/ptc/%d", opts.Epoch) + + httpResponse, err := s.post(ctx, + endpoint, + "", + &opts.Common, + bytes.NewReader(reqBody), + ContentTypeJSON, + map[string]string{}, + ) + if err != nil { + return nil, errors.Join(errors.New("failed to request PTC duties"), err) + } + + data, metadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*apiv1.PTCDuty{}) + if err != nil { + return nil, err + } + + slotsPerEpoch, err := s.SlotsPerEpoch(ctx) + if err != nil { + return nil, errors.Join(errors.New("failed to obtain slots per epoch"), err) + } + + if err := verifyPTCDuties(opts.Epoch, slotsPerEpoch, data); err != nil { + return nil, err + } + + return &api.Response[[]*apiv1.PTCDuty]{ + Metadata: metadata, + Data: data, + }, nil +} + +// verifyPTCDuties confirms that every duty returned by the node falls in the +// epoch that was requested. +func verifyPTCDuties(epoch phase0.Epoch, slotsPerEpoch uint64, duties []*apiv1.PTCDuty) error { + if slotsPerEpoch == 0 { + // Without this the end of the range underflows to MaxUint64 and the + // check below accepts every duty the node cares to return. + return errors.New("invalid slots per epoch 0") + } + + startSlot := phase0.Slot(uint64(epoch) * slotsPerEpoch) + endSlot := startSlot + phase0.Slot(slotsPerEpoch) - 1 + + for _, duty := range duties { + if duty == nil { + return errors.New("received nil PTC duty") + } + + if duty.Slot < startSlot || duty.Slot > endSlot { + return fmt.Errorf("received PTC duty for slot %d outside of range [%d,%d]", duty.Slot, startSlot, endSlot) + } + } + + return nil +} diff --git a/http/ptcduties_internal_test.go b/http/ptcduties_internal_test.go new file mode 100644 index 000000000..6ed0338c7 --- /dev/null +++ b/http/ptcduties_internal_test.go @@ -0,0 +1,94 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVerifyPTCDuties covers the epoch-range check applied to duties returned +// by the node. It is a pure function over plain Go values, so it needs no +// beacon node. +func TestVerifyPTCDuties(t *testing.T) { + duty := func(slot phase0.Slot) *apiv1.PTCDuty { + return &apiv1.PTCDuty{ValidatorIndex: 8, Slot: slot} + } + + tests := []struct { + name string + epoch phase0.Epoch + slotsPerEpoch uint64 + duties []*apiv1.PTCDuty + err string + }{ + { + name: "Empty", + epoch: 7537, + slotsPerEpoch: 8, + }, + { + name: "InRange", + epoch: 7537, + slotsPerEpoch: 8, + // Epoch 7537 at 8 slots per epoch spans [60296,60303]. + duties: []*apiv1.PTCDuty{duty(60296), duty(60300), duty(60303)}, + }, + { + name: "BeforeEpoch", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{duty(60295)}, + err: "received PTC duty for slot 60295 outside of range [60296,60303]", + }, + { + name: "AfterEpoch", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{duty(60300), duty(60304)}, + err: "received PTC duty for slot 60304 outside of range [60296,60303]", + }, + { + // A node reporting SLOTS_PER_EPOCH of 0 would otherwise underflow + // the end of the range to MaxUint64 and accept every duty, so the + // check would silently pass anything. + name: "ZeroSlotsPerEpoch", + epoch: 7537, + slotsPerEpoch: 0, + duties: []*apiv1.PTCDuty{duty(60300)}, + err: "invalid slots per epoch 0", + }, + { + name: "NilDuty", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{nil}, + err: "received nil PTC duty", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyPTCDuties(test.epoch, test.slotsPerEpoch, test.duties) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/http/ptcduties_test.go b/http/ptcduties_test.go new file mode 100644 index 000000000..700ed8953 --- /dev/null +++ b/http/ptcduties_test.go @@ -0,0 +1,109 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "errors" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// headSlot returns the slot of the node's current head. Every payload +// timeliness committee test anchors on it for the reason given on headEpoch +// below. +func headSlot(ctx context.Context, t *testing.T, service client.Service) phase0.Slot { + t.Helper() + + syncState, err := service.(client.NodeSyncingProvider).NodeSyncing(ctx, &api.NodeSyncingOpts{}) + require.NoError(t, err) + + return syncState.Data.HeadSlot +} + +// headEpoch returns the epoch of the node's current head. +// +// PTC duties are derived from the beacon state, so the epoch has to be one the +// node can actually build a state for. Deriving it from the head rather than +// from the wall clock keeps the test usable on a devnet whose chain has +// stalled, and is equally correct on a live chain, where the two coincide. +func headEpoch(ctx context.Context, t *testing.T, service client.Service) (phase0.Epoch, uint64) { + t.Helper() + + specResponse, err := service.(client.SpecProvider).Spec(ctx, &api.SpecOpts{}) + require.NoError(t, err) + + slotsPerEpoch, isCorrectType := specResponse.Data["SLOTS_PER_EPOCH"].(uint64) + require.True(t, isCorrectType) + require.NotZero(t, slotsPerEpoch) + + return phase0.Epoch(uint64(headSlot(ctx, t, service)) / slotsPerEpoch), slotsPerEpoch +} + +func TestPTCDuties(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + epoch, slotsPerEpoch := headEpoch(ctx, t, service) + + // Request every validator on the devnet so that the payload timeliness + // committee for at least one slot in the epoch is covered. + indices := make([]phase0.ValidatorIndex, 256) + for i := range indices { + indices[i] = phase0.ValidatorIndex(i) + } + + t.Run("NilOpts", func(t *testing.T) { + _, err := service.(client.PTCDutiesProvider).PTCDuties(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("NoIndices", func(t *testing.T) { + _, err := service.(client.PTCDutiesProvider).PTCDuties(ctx, &api.PTCDutiesOpts{ + Epoch: epoch, + }) + require.ErrorContains(t, err, "no validator indices specified") + require.True(t, errors.Is(err, client.ErrInvalidOptions)) + }) + + t.Run("Good", func(t *testing.T) { + response, err := service.(client.PTCDutiesProvider).PTCDuties(ctx, &api.PTCDutiesOpts{ + Epoch: epoch, + Indices: indices, + }) + require.NoError(t, err) + require.NotNil(t, response) + require.NotEmpty(t, response.Data, "no PTC duties returned for the head epoch") + + startSlot := phase0.Slot(uint64(epoch) * slotsPerEpoch) + endSlot := startSlot + phase0.Slot(slotsPerEpoch) - 1 + + for _, duty := range response.Data { + require.NotNil(t, duty) + require.GreaterOrEqual(t, duty.Slot, startSlot) + require.LessOrEqual(t, duty.Slot, endSlot) + require.NotEqual(t, phase0.BLSPubKey{}, duty.PubKey, "duty carries a zero public key") + } + + // The dependent root identifies the state the duties were computed + // from; a validator needs it to detect a reorg that changes them. + require.Contains(t, response.Metadata, "dependent_root") + }) +} diff --git a/http/submitpayloadattestationmessages.go b/http/submitpayloadattestationmessages.go new file mode 100644 index 000000000..5f048177d --- /dev/null +++ b/http/submitpayloadattestationmessages.go @@ -0,0 +1,125 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Service) SubmitPayloadAttestationMessages(ctx context.Context, + opts *api.SubmitPayloadAttestationMessagesOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if len(opts.Messages) == 0 { + return errors.Join(errors.New("no payload attestation messages supplied"), client.ErrInvalidOptions) + } + + unversioned, version, err := createUnversionedPayloadAttestationMessages(opts.Messages) + if err != nil { + return err + } + + reqBody, err := json.Marshal(unversioned) + if err != nil { + return errors.Join(errors.New("failed to marshal JSON"), err) + } + + endpoint := "/eth/v1/beacon/pool/payload_attestations" + + // The endpoint requires the consensus version of the messages being + // submitted, and rejects the request outright without it. + headers := map[string]string{ + "Eth-Consensus-Version": strings.ToLower(version.String()), + } + + if _, err := s.post(ctx, + endpoint, + "", + &opts.Common, + bytes.NewReader(reqBody), + ContentTypeJSON, + headers, + ); err != nil { + return errors.Join(errors.New("failed to submit payload attestation messages"), err) + } + + return nil +} + +// createUnversionedPayloadAttestationMessages strips the version wrapper from +// the messages, returning the bare messages to place on the wire along with +// the version they share. +func createUnversionedPayloadAttestationMessages(messages []*spec.VersionedPayloadAttestationMessage) ( + []any, + spec.DataVersion, + error, +) { + var ( + version spec.DataVersion + unversioned = make([]any, 0, len(messages)) + ) + + for i := range messages { + if messages[i] == nil { + return nil, spec.DataVersionUnknown, + errors.Join(errors.New("nil payload attestation message supplied"), client.ErrInvalidOptions) + } + + // Ensure consistent versioning. + if version == spec.DataVersionUnknown { + version = messages[i].Version + } else if version != messages[i].Version { + return nil, spec.DataVersionUnknown, errors.Join( + errors.New("payload attestation messages must all be of the same version"), + client.ErrInvalidOptions, + ) + } + + switch messages[i].Version { + case spec.DataVersionGloas: + if messages[i].Gloas == nil { + // Appending an unchecked arm here would put a null in the + // request body rather than a message. + return nil, spec.DataVersionUnknown, + errors.Join(errors.New("no gloas payload attestation message supplied"), client.ErrInvalidOptions) + } + + unversioned = append(unversioned, messages[i].Gloas) + default: + return nil, spec.DataVersionUnknown, errors.Join( + fmt.Errorf("unsupported payload attestation message version %s", messages[i].Version), + client.ErrInvalidOptions, + ) + } + } + + return unversioned, version, nil +} diff --git a/http/submitpayloadattestationmessages_internal_test.go b/http/submitpayloadattestationmessages_internal_test.go new file mode 100644 index 000000000..04cac1220 --- /dev/null +++ b/http/submitpayloadattestationmessages_internal_test.go @@ -0,0 +1,118 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "encoding/json" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func gloasMessage(validatorIndex phase0.ValidatorIndex) *spec.VersionedPayloadAttestationMessage { + return &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: validatorIndex, + Data: &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01}, + Slot: 60300, + PayloadPresent: true, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{0xaa}, + }, + } +} + +// TestCreateUnversionedPayloadAttestationMessages covers the step that strips +// the version wrapper before the messages go on the wire. It is pure, so it +// needs no beacon node. +func TestCreateUnversionedPayloadAttestationMessages(t *testing.T) { + tests := []struct { + name string + messages []*spec.VersionedPayloadAttestationMessage + expected int + err string + }{ + { + name: "Single", + messages: []*spec.VersionedPayloadAttestationMessage{gloasMessage(8)}, + expected: 1, + }, + { + name: "Multiple", + messages: []*spec.VersionedPayloadAttestationMessage{gloasMessage(8), gloasMessage(18)}, + expected: 2, + }, + { + name: "NilMessage", + messages: []*spec.VersionedPayloadAttestationMessage{nil}, + err: "nil payload attestation message supplied", + }, + { + // Naming a version whose arm is nil must be rejected rather than + // appended: an unchecked arm marshals to a body of [null], which + // the node accepts as well-formed JSON and then mis-handles. + name: "NilArm", + messages: []*spec.VersionedPayloadAttestationMessage{ + {Version: spec.DataVersionGloas}, + }, + err: "no gloas payload attestation message supplied", + }, + { + name: "MixedVersions", + messages: []*spec.VersionedPayloadAttestationMessage{ + gloasMessage(8), + {Version: spec.DataVersionElectra}, + }, + err: "payload attestation messages must all be of the same version", + }, + { + name: "UnsupportedVersion", + messages: []*spec.VersionedPayloadAttestationMessage{ + {Version: spec.DataVersionElectra}, + }, + err: "unsupported payload attestation message version electra", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + unversioned, version, err := createUnversionedPayloadAttestationMessages(test.messages) + if test.err != "" { + require.ErrorContains(t, err, test.err) + require.ErrorIs(t, err, client.ErrInvalidOptions) + + return + } + + require.NoError(t, err) + require.Len(t, unversioned, test.expected) + require.Equal(t, spec.DataVersionGloas, version) + + // The body must be an array of bare messages, with no version + // wrapper and no nulls in it. + encoded, err := json.Marshal(unversioned) + require.NoError(t, err) + require.NotContains(t, string(encoded), "null") + require.NotContains(t, string(encoded), "\"version\"") + require.Contains(t, string(encoded), "\"validator_index\":\"8\"") + }) + } +} diff --git a/http/submitpayloadattestationmessages_test.go b/http/submitpayloadattestationmessages_test.go new file mode 100644 index 000000000..cf89c58c1 --- /dev/null +++ b/http/submitpayloadattestationmessages_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 http_test + +import ( + "context" + "errors" + "strings" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestSubmitPayloadAttestationMessages(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + slot := headSlot(ctx, t, service) + + t.Run("NilOpts", func(t *testing.T) { + err := service.(client.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("NoMessages", func(t *testing.T) { + err := service.(client.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, + &api.SubmitPayloadAttestationMessagesOpts{}, + ) + require.ErrorContains(t, err, "no payload attestation messages supplied") + require.ErrorIs(t, err, client.ErrInvalidOptions) + }) + + // Reaching the server matters more than the submission succeeding: only a + // PTC member holding the right key can produce a valid signature, so the + // node is expected to reject this. What it rejects on is the point. A + // missing or wrong Eth-Consensus-Version header is refused before the + // messages are looked at, so a rejection that names the signature is + // evidence that the header and the body shape were both accepted. + t.Run("ReachesServer", func(t *testing.T) { + err := service.(client.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, + &api.SubmitPayloadAttestationMessagesOpts{ + Messages: []*spec.VersionedPayloadAttestationMessage{ + { + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: 8, + Data: &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01}, + Slot: slot, + PayloadPresent: true, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{0xa0}, + }, + }, + }, + }, + ) + 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) + + body := strings.ToLower(string(apiErr.Data)) + require.NotContains(t, body, "eth-consensus-version", + "the node rejected the consensus version header, so the request never reached message validation") + require.True(t, + strings.Contains(body, "signature") || strings.Contains(body, "failed validation"), + "expected a rejection from message validation, got %s", body) + }) +} diff --git a/mock/payloadattestationdata.go b/mock/payloadattestationdata.go new file mode 100644 index 000000000..8db7da629 --- /dev/null +++ b/mock/payloadattestationdata.go @@ -0,0 +1,46 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + if s.PayloadAttestationDataFunc != nil { + return s.PayloadAttestationDataFunc(ctx, opts) + } + + return &api.Response[*spec.VersionedPayloadAttestationData]{ + Data: &spec.VersionedPayloadAttestationData{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationData{ + Slot: opts.Slot, + PayloadPresent: true, + BlobDataAvailable: true, + }, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/payloadattestationpool.go b/mock/payloadattestationpool.go new file mode 100644 index 000000000..5510763e1 --- /dev/null +++ b/mock/payloadattestationpool.go @@ -0,0 +1,57 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (*Service) PayloadAttestationPool(_ context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + var slot phase0.Slot + if opts.Slot != nil { + slot = *opts.Slot + } + + data := make([]*spec.VersionedPayloadAttestation, 2) + for i := range data { + data[i] = &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{ + AggregationBits: bitfield.NewBitvector512(), + Data: &gloas.PayloadAttestationData{ + Slot: slot, + PayloadPresent: true, + BlobDataAvailable: true, + }, + }, + } + } + + return &api.Response[[]*spec.VersionedPayloadAttestation]{ + Data: data, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/ptcduties.go b/mock/ptcduties.go new file mode 100644 index 000000000..41d1f552a --- /dev/null +++ b/mock/ptcduties.go @@ -0,0 +1,47 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + if s.PTCDutiesFunc != nil { + return s.PTCDutiesFunc(ctx, opts) + } + + data := make([]*apiv1.PTCDuty, len(opts.Indices)) + for i := range opts.Indices { + data[i] = &apiv1.PTCDuty{ + ValidatorIndex: opts.Indices[i], + Slot: phase0.Slot(opts.Epoch), + } + } + + return &api.Response[[]*apiv1.PTCDuty]{ + Data: data, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/service.go b/mock/service.go index d0324696d..c73b77341 100644 --- a/mock/service.go +++ b/mock/service.go @@ -70,6 +70,8 @@ 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) diff --git a/mock/submitpayloadattestationmessages.go b/mock/submitpayloadattestationmessages.go new file mode 100644 index 000000000..08229f3c4 --- /dev/null +++ b/mock/submitpayloadattestationmessages.go @@ -0,0 +1,27 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (*Service) SubmitPayloadAttestationMessages(_ context.Context, + _ *api.SubmitPayloadAttestationMessagesOpts, +) error { + return nil +} diff --git a/multi/payloadattestationdata.go b/multi/payloadattestationdata.go new file mode 100644 index 000000000..cc3c08004 --- /dev/null +++ b/multi/payloadattestationdata.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + data, err := client.(consensusclient.PayloadAttestationDataProvider).PayloadAttestationData(ctx, opts) + if err != nil { + return nil, err + } + + return data, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*spec.VersionedPayloadAttestationData]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/payloadattestationpool.go b/multi/payloadattestationpool.go new file mode 100644 index 000000000..16e203649 --- /dev/null +++ b/multi/payloadattestationpool.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (s *Service) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + pool, err := client.(consensusclient.PayloadAttestationPoolProvider).PayloadAttestationPool(ctx, opts) + if err != nil { + return nil, err + } + + return pool, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[[]*spec.VersionedPayloadAttestation]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/ptcduties.go b/multi/ptcduties.go new file mode 100644 index 000000000..584875396 --- /dev/null +++ b/multi/ptcduties.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + duties, err := client.(consensusclient.PTCDutiesProvider).PTCDuties(ctx, opts) + if err != nil { + return nil, err + } + + return duties, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[[]*apiv1.PTCDuty]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/submitpayloadattestationmessages.go b/multi/submitpayloadattestationmessages.go new file mode 100644 index 000000000..46a268ced --- /dev/null +++ b/multi/submitpayloadattestationmessages.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Service) SubmitPayloadAttestationMessages(ctx context.Context, + opts *api.SubmitPayloadAttestationMessagesOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/providerparity_test.go b/providerparity_test.go new file mode 100644 index 000000000..a48a6b397 --- /dev/null +++ b/providerparity_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 client_test + +import ( + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/multi" + "github.com/attestantio/go-eth2-client/testclients" +) + +// A provider interface is only useful if every implementation carries it, but +// nothing in this repository forces that: the implementations are reached by +// type assertion at run time, so a missing method in multi, mock or a test +// client compiles cleanly and fails only when a caller asserts on it. +// +// These declarations turn that into a build failure. They currently cover the +// payload timeliness committee surface, which the older providers predate; new +// providers belong in the block below rather than in a file of their own, so +// that the registry grows in one place. +var ( + _ 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) +) diff --git a/service.go b/service.go index ba9838883..9459672f2 100644 --- a/service.go +++ b/service.go @@ -718,3 +718,51 @@ type ExecutionPayloadBidSubmitter interface { opts *api.SubmitExecutionPayloadBidOpts, ) error } + +// PTCDutiesProvider is the interface for providing payload timeliness +// committee duties. +type PTCDutiesProvider interface { + // PTCDuties obtains payload timeliness committee duties. + 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 obtains payload attestation data for the given + // options. It returns ErrNoPayloadAttestationData if the node has seen no + // block for the slot, in which case the validator must not attest. + 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 fetches the payload attestation pool for the + // given options. + 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 submits payload attestation messages. + SubmitPayloadAttestationMessages(ctx context.Context, + opts *api.SubmitPayloadAttestationMessagesOpts, + ) error +} diff --git a/spec/versionedpayloadattestation.go b/spec/versionedpayloadattestation.go new file mode 100644 index 000000000..c82240ae9 --- /dev/null +++ b/spec/versionedpayloadattestation.go @@ -0,0 +1,109 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestation contains a versioned payload attestation +// (EIP-7732): the aggregate form held in the operations pool, in which a +// single signature covers the payload timeliness committee members named by +// the aggregation bits. It is a gloas-onwards container; the wrapper exists +// so callers can branch on Version uniformly with the other versioned types. +type VersionedPayloadAttestation struct { + Version DataVersion + Gloas *gloas.PayloadAttestation +} + +// IsEmpty returns true if no fork-specific payload attestation is populated. +func (v *VersionedPayloadAttestation) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestation) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Data returns the data being attested to. +func (v *VersionedPayloadAttestation) Data() (*gloas.PayloadAttestationData, error) { + attestation, err := v.attestation() + if err != nil { + return nil, err + } + + if attestation.Data == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return attestation.Data, nil +} + +// AggregationBits returns the bits of the payload timeliness committee covered +// by the attestation's signature. +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 000000000..7ba0339e5 --- /dev/null +++ b/spec/versionedpayloadattestation_test.go @@ -0,0 +1,99 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestVersionedPayloadAttestationAccessors(t *testing.T) { + bits := bitfield.NewBitvector512() + bits.SetBitAt(3, true) + + populated := &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{ + AggregationBits: bits, + Data: testPayloadAttestationData(), + Signature: phase0.BLSSignature{0xaa}, + }, + } + nilArm := &spec.VersionedPayloadAttestation{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestation{Version: spec.DataVersionFulu} + unknown := &spec.VersionedPayloadAttestation{Version: spec.DataVersion(99)} + + t.Run("Data", func(t *testing.T) { + data, err := populated.Data() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), data.Slot) + + _, err = nilArm.Data() + require.EqualError(t, err, "no gloas payload attestation") + + _, err = preGloas.Data() + require.EqualError(t, err, "no payload attestation in fulu") + + _, err = unknown.Data() + require.EqualError(t, err, "unknown version") + }) + + t.Run("AggregationBits", func(t *testing.T) { + aggregationBits, err := populated.AggregationBits() + require.NoError(t, err) + require.True(t, aggregationBits.BitAt(3)) + require.False(t, aggregationBits.BitAt(4)) + + _, 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 000000000..4d426f885 --- /dev/null +++ b/spec/versionedpayloadattestationdata.go @@ -0,0 +1,112 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestationData contains a versioned payload attestation +// data (EIP-7732). This is the datum a payload timeliness committee member +// signs, and is a gloas-onwards container; the wrapper exists so callers can +// branch on Version uniformly with the other versioned types. +type VersionedPayloadAttestationData struct { + Version DataVersion + Gloas *gloas.PayloadAttestationData +} + +// IsEmpty returns true if no fork-specific payload attestation data is populated. +func (v *VersionedPayloadAttestationData) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestationData) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Slot returns the slot of the payload attestation data. +func (v *VersionedPayloadAttestationData) Slot() (phase0.Slot, error) { + data, err := v.data() + if err != nil { + return 0, err + } + + return data.Slot, nil +} + +// BeaconBlockRoot returns the root of the beacon block being attested to. +func (v *VersionedPayloadAttestationData) BeaconBlockRoot() (phase0.Root, error) { + data, err := v.data() + if err != nil { + return phase0.Root{}, err + } + + return data.BeaconBlockRoot, nil +} + +// PayloadPresent returns true if the execution payload was revealed on time. +func (v *VersionedPayloadAttestationData) PayloadPresent() (bool, error) { + data, err := v.data() + if err != nil { + return false, err + } + + return data.PayloadPresent, nil +} + +// BlobDataAvailable returns true if the blob data for the payload was available. +func (v *VersionedPayloadAttestationData) BlobDataAvailable() (bool, error) { + data, err := v.data() + if err != nil { + return false, err + } + + return data.BlobDataAvailable, nil +} + +// data returns the fork-specific payload attestation data, or an error +// explaining why there is none. Every accessor funnels through here so that +// the version and nil checks cannot drift between them. +func (v *VersionedPayloadAttestationData) data() (*gloas.PayloadAttestationData, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, fmt.Errorf("no payload attestation data in %s", v.Version) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return v.Gloas, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedpayloadattestationdata_test.go b/spec/versionedpayloadattestationdata_test.go new file mode 100644 index 000000000..30951c37a --- /dev/null +++ b/spec/versionedpayloadattestationdata_test.go @@ -0,0 +1,108 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// testPayloadAttestationData is the datum a PTC validator signs: the block it +// is voting on, plus the two timeliness flags. +// +// The two flags are deliberately set apart. Both are bool and both mean +// "something about this payload is fine", so an accessor wired to the other +// one's field is invisible to any test built on a fixture that sets them +// alike — and getting it wrong makes a validator vote the opposite of what it +// observed. +func testPayloadAttestationData() *gloas.PayloadAttestationData { + return &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01, 0x02, 0x03}, + Slot: 60300, + PayloadPresent: true, + BlobDataAvailable: false, + } +} + +func TestVersionedPayloadAttestationDataAccessors(t *testing.T) { + populated := &spec.VersionedPayloadAttestationData{ + Version: spec.DataVersionGloas, + Gloas: testPayloadAttestationData(), + } + nilArm := &spec.VersionedPayloadAttestationData{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestationData{Version: spec.DataVersionElectra} + unknown := &spec.VersionedPayloadAttestationData{Version: spec.DataVersion(99)} + + t.Run("Slot", func(t *testing.T) { + slot, err := populated.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), slot) + + _, err = nilArm.Slot() + require.EqualError(t, err, "no gloas payload attestation data") + + _, err = preGloas.Slot() + require.EqualError(t, err, "no payload attestation data in electra") + + _, err = unknown.Slot() + require.EqualError(t, err, "unknown version") + }) + + t.Run("BeaconBlockRoot", func(t *testing.T) { + root, err := populated.BeaconBlockRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root{0x01, 0x02, 0x03}, root) + + _, err = nilArm.BeaconBlockRoot() + require.EqualError(t, err, "no gloas payload attestation data") + + _, err = preGloas.BeaconBlockRoot() + require.EqualError(t, err, "no payload attestation data in electra") + }) + + t.Run("PayloadPresent", func(t *testing.T) { + present, err := populated.PayloadPresent() + require.NoError(t, err) + require.True(t, present) + + _, err = nilArm.PayloadPresent() + require.EqualError(t, err, "no gloas payload attestation data") + }) + + t.Run("BlobDataAvailable", func(t *testing.T) { + available, err := populated.BlobDataAvailable() + require.NoError(t, err) + require.False(t, available) + + _, err = nilArm.BlobDataAvailable() + require.EqualError(t, err, "no gloas payload attestation data") + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, nilArm.IsEmpty()) + require.True(t, preGloas.IsEmpty()) + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "60300") + require.Empty(t, nilArm.String()) + require.Empty(t, preGloas.String()) + require.Equal(t, "unknown version", unknown.String()) + }) +} diff --git a/spec/versionedpayloadattestationmessage.go b/spec/versionedpayloadattestationmessage.go new file mode 100644 index 000000000..3bd248f19 --- /dev/null +++ b/spec/versionedpayloadattestationmessage.go @@ -0,0 +1,107 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestationMessage contains a versioned payload attestation +// message (EIP-7732): the unaggregated form a single payload timeliness +// committee member broadcasts. It is a gloas-onwards container; the wrapper +// exists so that the Eth-Consensus-Version header required when submitting one +// is derived from the caller's data rather than hard-coded. +type VersionedPayloadAttestationMessage struct { + Version DataVersion + Gloas *gloas.PayloadAttestationMessage +} + +// IsEmpty returns true if no fork-specific message is populated. +func (v *VersionedPayloadAttestationMessage) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestationMessage) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// ValidatorIndex returns the index of the attesting validator. +func (v *VersionedPayloadAttestationMessage) ValidatorIndex() (phase0.ValidatorIndex, error) { + message, err := v.message() + if err != nil { + return 0, err + } + + return message.ValidatorIndex, nil +} + +// Data returns the data being attested to. +func (v *VersionedPayloadAttestationMessage) Data() (*gloas.PayloadAttestationData, error) { + message, err := v.message() + if err != nil { + return nil, err + } + + if message.Data == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return message.Data, nil +} + +// Signature returns the signature of the message. +func (v *VersionedPayloadAttestationMessage) Signature() (phase0.BLSSignature, error) { + message, err := v.message() + if err != nil { + return phase0.BLSSignature{}, err + } + + return message.Signature, nil +} + +// message returns the fork-specific message, or an error explaining why there +// is none. Every accessor funnels through here so that the version and nil +// checks cannot drift between them. +func (v *VersionedPayloadAttestationMessage) message() (*gloas.PayloadAttestationMessage, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, fmt.Errorf("no payload attestation message in %s", v.Version) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas payload attestation message") + } + + return v.Gloas, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedpayloadattestationmessage_test.go b/spec/versionedpayloadattestationmessage_test.go new file mode 100644 index 000000000..2256aa819 --- /dev/null +++ b/spec/versionedpayloadattestationmessage_test.go @@ -0,0 +1,93 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestVersionedPayloadAttestationMessageAccessors(t *testing.T) { + populated := &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: 8, + Data: testPayloadAttestationData(), + Signature: phase0.BLSSignature{0xbb}, + }, + } + nilArm := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersionDeneb} + unknown := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersion(99)} + + t.Run("ValidatorIndex", func(t *testing.T) { + index, err := populated.ValidatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(8), index) + + _, err = nilArm.ValidatorIndex() + require.EqualError(t, err, "no gloas payload attestation message") + + _, err = preGloas.ValidatorIndex() + require.EqualError(t, err, "no payload attestation message in deneb") + + _, err = unknown.ValidatorIndex() + require.EqualError(t, err, "unknown version") + }) + + t.Run("Data", func(t *testing.T) { + data, err := populated.Data() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), data.Slot) + + _, err = nilArm.Data() + require.EqualError(t, err, "no gloas payload attestation message") + }) + + t.Run("Signature", func(t *testing.T) { + sig, err := populated.Signature() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, sig) + + _, err = nilArm.Signature() + require.EqualError(t, err, "no gloas payload attestation message") + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, nilArm.IsEmpty()) + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "60300") + require.Empty(t, nilArm.String()) + require.Equal(t, "unknown version", unknown.String()) + }) + + // The message is what gets submitted to the node; a nil Data would be + // serialised as a null datum rather than rejected. + t.Run("NilData", func(t *testing.T) { + partial := &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ValidatorIndex: 8}, + } + + _, err := partial.Data() + require.EqualError(t, err, "no gloas payload attestation data") + }) +} diff --git a/testclients/erroring.go b/testclients/erroring.go index f84e71971..3351478c1 100644 --- a/testclients/erroring.go +++ b/testclients/erroring.go @@ -1143,3 +1143,76 @@ func (s *Erroring) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api return next.SubmitExecutionPayloadEnvelope(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, isNext := s.next.(consensusclient.PTCDutiesProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationDataProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationPoolProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationMessagesSubmitter) + if !isNext { + return fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SubmitPayloadAttestationMessages(ctx, opts) +} diff --git a/testclients/sleepy.go b/testclients/sleepy.go index 1d0afdbd7..12a268e3e 100644 --- a/testclients/sleepy.go +++ b/testclients/sleepy.go @@ -873,3 +873,68 @@ func (s *Sleepy) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.S return next.SubmitExecutionPayloadEnvelope(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, isNext := s.next.(consensusclient.PTCDutiesProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationDataProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationPoolProvider) + if !isNext { + 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, isNext := s.next.(consensusclient.PayloadAttestationMessagesSubmitter) + if !isNext { + return errors.New("next does not support this call") + } + + return next.SubmitPayloadAttestationMessages(ctx, opts) +} From bf361eac676b2addb4b5b0cb336297ff13943f3f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 29 Jul 2026 13:01:15 +0200 Subject: [PATCH 14/37] de-duplicate the ePBS request marshal and the api/v1 hex decode Two copy-paste duplications introduced by the ePBS transport work, both "use the abstraction that already exists", both zero behaviour change. The request-body content-type negotiation over an `any` value -- JSON when enforced, otherwise the dynssz codec -- existed twice: once as the private method submitExecutionPayloadEnvelopeData, once inline inside postExecutionPayloadBid. The two produced identical error values, so a change to negotiation had to land in both places and could silently drift. They are now one method, marshalRequestBody, called from both submitters. Separately, decodeFixedBytes (trim 0x, hex-decode, length-check, copy) had two callers, both inside its own defining file, while fastconfirmationevent.go and executionpayloadavailableevent.go hand-rolled the identical sequence. Both now call it: four callers, two of them outside the defining file. The interesting part was proving "no behaviour change" rather than asserting it, because the two halves had opposite safety nets. The api/v1 targets were already fully covered -- the existing tests pin the exact error strings, down to `incorrect length 31 for block` -- so those tests passing unmodified is the proof, and git confirms no test file was touched. The http targets had none: the JSON arm of both submitters had zero coverage, since WithEnforceJSON is only ever exercised against responses. A broken extraction there would have shipped green, so the negotiation got a test first. Key decisions: - decodeFixedBytes lost its wantLen parameter, deriving the required length from len(dst). This was forced: taking the callers from two to four, all passing 32, made unparam report `wantLen always receives 32`. It is a true positive, so the parameter went rather than a nolint. Every caller passes a slice of a full fixed-size array, so len(dst) is the same constant 32 it replaces, and it closes a real hazard -- a wantLen larger than len(dst) would previously pass the guard and then let copy silently truncate, admitting a wrong root. Verified two ways: hard-coding 32 in place of len(dst) leaves the suite green, and reverting to the four-argument form reproduces the unparam finding while the original two call sites do not trigger it at all. - marshalRequestBody lives in its own file rather than in either caller or in http.go. Its two callers are in different files, so either would make ownership arbitrary, and the package already keeps small single-purpose helpers this way (json.go, contenttype.go, domain.go, stateid.go). The response-side negotiation in http.go is the Accept header, a different concern. - submitproposal.go was deliberately left alone despite also branching on enforceJSON. It dispatches per fork version over a typed *api.VersionedSignedProposal rather than marshalling one already-selected `any`, so it cannot use this helper; unifying them would be a mismatch, not a missed opportunity. - ptcduty.go keeps its hand-rolled decode. Its length error is the bare `incorrect length for public key`, pinned verbatim by its own test, so converting it would change the emitted string and break that test -- the one thing this change must not do. Duty types are also outside the scope of the event-type de-duplication. - The new test builds a bare &Service{} instead of going through the constructor. With customSpecSupport false, dynSSZForRequest returns the process-global codec and makes no network call, so both arms are reachable in-process; this matches the package's existing internal tests, which construct httpResponse values directly. - errors.go gets a copyright-year bump. That is a pre-existing lint failure, not fallout from this change: it reproduces at clean HEAD with a cold lint cache, and was previously masked because golangci-lint had the root package cached. Editing api/v1 invalidates that cache, since the root package imports it, which is the only reason it surfaced here. Without the bump the lint gate is not green. Files changed: - new: http/marshalrequestbody.go, http/marshalrequestbody_internal_test.go - modified: http/submitexecutionpayloadbid.go (inline negotiation deleted), http/submitexecutionpayloadenvelope.go (submitExecutionPayloadEnvelopeData deleted) - modified: api/v1/executionpayloadevent.go (helper signature, 2 call sites), api/v1/fastconfirmationevent.go, api/v1/executionpayloadavailableevent.go - modified: errors.go (copyright header only) Net -79/+9 production lines plus the helper and its test. No test file that existed before this change was modified. Verified: go build ./... clean; gosilent test ./... 2306 green, matching the pre-change count exactly because http/main_test.go returns without calling m.Run() unless HTTP_ADDRESS is set; -race clean on api/v1; gofmt and go vet clean on all eight files; custom-gcl 0 issues repo-wide on a cold cache. Note .golangci.yml sets run.tests: false, so lint never inspects the test files -- go vet and gofmt are what cover them. Against a live Glamsterdam devnet-7 prysm node the full http suite was run before and after: the failure set is byte-identical across all 147 entries, and the only new passes are the five belonging to the new test. Those 147 are pre-existing devnet conditions, dominated by 107 "client is not active" that begin once the live-network tests start and cascade; every in-process test passes in both runs. Coverage is mutation-proven, not assumed. Ten mutations, each failing exactly its own subtests: inverting the negotiation arm fails all four of the new subtests; dropping the SSZ error wrap fails only SSZMarshalFailure; returning a content type other than Unknown on JSON-marshal failure fails only JSONMarshalFailure; making the length guard one-sided fails only the over-length cases across all three event types; dropping the %d from the helper's error fails exactly the length subtests; neutering its copy fails exactly the round-trip subtests. The two that matter most are the pair that must stay green: hard-coding 32 for len(dst), which proves the signature change is behaviour-identical rather than merely untested. Five reviewers ran and all approve. Security built a 24-case differential harness running each hostile input through both the old wantLen logic and the new len(dst) form and found zero divergence in accept/reject decision or destination bytes. Efficiency measured escape analysis and benchmarks: dst does not escape at any call site and there is no new allocation or boxing. After one round of trims, all five approve. Blockers / notes for next iteration: - The devnet chain is dead and this is the more important note. Both consensus clients have zero peers and are around 24,000 slots behind wall clock, with the execution clients stalled and divergent. Lighthouse additionally cannot run epoch processing forward -- it reports EpochProcessingError(BeaconStateError(InvalidIndicesCount)) -- so every validator duties endpoint returns 500, and it reports is_syncing true, which the synced assertion refuses. Prysm reports is_syncing false on the same chain because it derives that flag from sync-service activity and with no peers there is nothing to do, which is the only reason it is usable as a test target. Recreating the enclave is needed before any chain-dependent verification means anything; Lighthouse may still fail epoch processing afterwards, which would be a client bug worth reporting upstream. - Asking prysm for attestation data at a wall-clock slot never returns on this chain, while the same call at its head fails in a millisecond. aggregateattestation_test.go derives its slot from wall clock, so it blocks for the full client timeout and every live test after it fails with "client is not active". That cascade is what produces most of the 147 failures, and it means the shared-service harness turns one timed-out request into a package-wide failure. Worth fixing independently of any fork work. - No http/ test can gate CI, since TestMain returns without calling m.Run() unless HTTP_ADDRESS is set. The new negotiation test is therefore invisible to ordinary CI even though it needs no network; the api/v1 coverage, which is what proves the decode half, is not. Tracked separately. - proposerduty.go already emits the %d-parameterised length error that decodeFixedBytes produces, and its own test pins that form, so converting it would be a safe zero-behaviour-change adoption. Its two duty siblings use the bare errors.New form and would not be. Any wider sweep should check each file individually rather than assuming the four duty types share one constraint. - About forty further copies of the hand-rolled decode remain across nineteen other api/v1 files, payloadattributesevent.go alone holding twelve. They are deliberately untouched: the house form emits a message without the %d and many tests pin it, so a wider adoption is a repo-wide sweep that has to pick one error string and update the assertions that pin the other, not a per-package change. - golangci-lint is non-deterministic here in a way worth knowing about. It reported a nolintlint finding in spec/capella once and then reported zero issues on the two following runs, cold and warm, in a file this change does not touch. The same cache behaviour is what hid the errors.go finding. A single green lint run is not strong evidence; clean the cache and repeat. --- api/v1/executionpayloadavailableevent.go | 11 +-- api/v1/executionpayloadevent.go | 13 ++-- api/v1/fastconfirmationevent.go | 13 +--- errors.go | 2 +- http/marshalrequestbody.go | 47 +++++++++++++ http/marshalrequestbody_internal_test.go | 86 ++++++++++++++++++++++++ http/submitexecutionpayloadbid.go | 29 +------- http/submitexecutionpayloadenvelope.go | 34 +--------- 8 files changed, 149 insertions(+), 86 deletions(-) create mode 100644 http/marshalrequestbody.go create mode 100644 http/marshalrequestbody_internal_test.go diff --git a/api/v1/executionpayloadavailableevent.go b/api/v1/executionpayloadavailableevent.go index 35587ba50..84ed88ddb 100644 --- a/api/v1/executionpayloadavailableevent.go +++ b/api/v1/executionpayloadavailableevent.go @@ -14,11 +14,9 @@ package v1 import ( - "encoding/hex" "encoding/json" "fmt" "strconv" - "strings" "github.com/attestantio/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/executionpayloadevent.go b/api/v1/executionpayloadevent.go index a05dc5df5..3a63a5901 100644 --- a/api/v1/executionpayloadevent.go +++ b/api/v1/executionpayloadevent.go @@ -83,7 +83,7 @@ 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 } @@ -96,7 +96,7 @@ func (e *ExecutionPayloadEvent) UnmarshalJSON(input []byte) error { } if data.BlockHash != "" { - if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, phase0.Hash32Length, "block hash"); err != nil { + if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, "block hash"); err != nil { return err } } @@ -116,14 +116,15 @@ func (e *ExecutionPayloadEvent) String() string { return string(data) } -// decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly -// wantLen bytes. -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/fastconfirmationevent.go b/api/v1/fastconfirmationevent.go index 06b724192..bd72cf200 100644 --- a/api/v1/fastconfirmationevent.go +++ b/api/v1/fastconfirmationevent.go @@ -14,11 +14,9 @@ package v1 import ( - "encoding/hex" "encoding/json" "fmt" "strconv" - "strings" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" @@ -71,17 +69,10 @@ 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 len(block) != rootLength { - return fmt.Errorf("incorrect length %d for block", len(block)) + if err := decodeFixedBytes(e.Block[:], data.Block, "block"); err != nil { + return err } - copy(e.Block[:], block) - // current_slot was added to the spec after slot/block; parse it when // present but tolerate clients that do not yet emit it. if data.CurrentSlot != "" { diff --git a/errors.go b/errors.go index 8b1da982a..187769d54 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 diff --git a/http/marshalrequestbody.go b/http/marshalrequestbody.go new file mode 100644 index 000000000..7afdf2c53 --- /dev/null +++ b/http/marshalrequestbody.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 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 + } + + 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 000000000..b50187d6a --- /dev/null +++ b/http/marshalrequestbody_internal_test.go @@ -0,0 +1,86 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// TestMarshalRequestBody pins the request-body content-type negotiation shared +// by the ePBS submitters: SSZ by default, JSON when enforced, and the error +// wrapping of each arm. +// +// This needs no beacon node. The negotiation is pure logic given a Service: +// with customSpecSupport false, dynSSZForRequest returns the process-global +// codec and makes no network call, and neither arm touches any other field. +func TestMarshalRequestBody(t *testing.T) { + ctx := context.Background() + + bid := &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{}, + } + + // Expected SSZ from the type's own generated static codec: fastssz, a + // different implementation from the reflective dynssz codec under test. + wantSSZ, err := bid.MarshalSSZ() + require.NoError(t, err) + + t.Run("SSZByDefault", func(t *testing.T) { + s := &Service{} + + body, contentType, err := s.marshalRequestBody(ctx, bid) + require.NoError(t, err) + require.Equal(t, ContentTypeSSZ, contentType) + require.Equal(t, wantSSZ, body) + }) + + t.Run("JSONWhenEnforced", func(t *testing.T) { + s := &Service{enforceJSON: true} + + body, contentType, err := s.marshalRequestBody(ctx, bid) + require.NoError(t, err) + require.Equal(t, ContentTypeJSON, contentType) + + // JSONEq rather than a decode-and-deep-equal round trip: this type's + // JSON codec normalises a nil BlobKZGCommitments to an empty slice, so + // a round trip would fail on the codec, not on the negotiation. + wantJSON, err := bid.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(body)) + }) + + t.Run("JSONMarshalFailure", func(t *testing.T) { + s := &Service{enforceJSON: true} + + body, contentType, err := s.marshalRequestBody(ctx, make(chan int)) + require.EqualError(t, err, "failed to marshal JSON\njson: unsupported type: chan int") + require.Nil(t, body) + require.Equal(t, ContentTypeUnknown, contentType) + }) + + t.Run("SSZMarshalFailure", func(t *testing.T) { + s := &Service{} + + body, contentType, err := s.marshalRequestBody(ctx, make(chan int)) + // ErrorContains, not EqualError: the text past our own wrapper comes + // from dynssz, whose wording this package does not own. + require.ErrorContains(t, err, "failed to marshal SSZ") + require.Nil(t, body) + require.Equal(t, ContentTypeUnknown, contentType) + }) +} diff --git a/http/submitexecutionpayloadbid.go b/http/submitexecutionpayloadbid.go index 578919878..9fee11cd4 100644 --- a/http/submitexecutionpayloadbid.go +++ b/http/submitexecutionpayloadbid.go @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "fmt" "strings" @@ -71,31 +70,9 @@ func (s *Service) postExecutionPayloadBid(ctx context.Context, 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/submitexecutionpayloadenvelope.go b/http/submitexecutionpayloadenvelope.go index e92cc8311..7cb9ce041 100644 --- a/http/submitexecutionpayloadenvelope.go +++ b/http/submitexecutionpayloadenvelope.go @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "fmt" "strings" @@ -73,7 +72,7 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, ) } - body, contentType, err := s.submitExecutionPayloadEnvelopeData(ctx, contents) + body, contentType, err := s.marshalRequestBody(ctx, contents) if err != nil { return err } @@ -112,37 +111,6 @@ 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). From adb7c3fcb81279cf05e77a1450491eaefdffd698 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 29 Jul 2026 21:44:59 +0200 Subject: [PATCH 15/37] add the ePBS block-production types: gloas block contents and the proposal wrapper Groundwork for the gloas-onwards block production endpoint (GET /eth/v4/validator/blocks/{slot}). The endpoint itself is not here; this is the type surface it returns, landed separately because it is independently verifiable without a node and because the endpoint's shape depends on it. None of this is a port. The ethpandaops fork has no v4 support on any branch -- its proposal.go is still on /eth/v3/validator/blocks/%d on both gloas and master, and no v4 file exists -- so every wire detail was read from the beacon-APIs spec at commit 4b4d89a and not pattern-matched from the v3 endpoint. Reading the spec rather than trusting a second-hand summary immediately paid for itself. produceBlockV4 was documented internally as having no execution_payload_value, on the reasoning that payload value now arrives via the bid, so the existing ExecutionValue concept did not apply. That was true when written and is now false: execution_payload_value was added as a required response field with a matching Eth-Execution-Payload-Value header by beacon-APIs PR #631, which is the current tip. Traced with `git log -S execution_payload_value -- apis/validator/block.v4.yaml` against a fresh clone rather than inferred. The wrapper therefore carries both value components. Key decisions: - BlockContents mirrors SignedExecutionPayloadEnvelopeContents rather than api/v1/fulu.BlockContents, which is the closer-looking analogue. Fulu's tags are ssz-max/ssz-size only, so its generated codec is correct at the mainnet preset alone; the envelope contents added on this branch carry dynssz-max as well. The validation devnet runs the minimal preset, so the mainnet-only form would have been undecodable exactly where it is first exercised. - IncludePayload is *bool, not bool. It is the axis that replaced blinded versus unblinded, and it is an operational cliff rather than a formatting flag: true means the envelope travels with the block so any node can publish it, false means the producing node caches it and must also be the publisher. The spec marks the parameter required with no default. A Go bool would zero-value to false, silently selecting the mode that constrains where the block can be published, so absence is made representable and will be rejected rather than resolved. - The wrapper keeps execution_payload_included as a flag over two arms, which is the shape the existing proposal wrapper already uses for blinded. Worth noting the inversion: there the contents arm is the unblinded one, here the contents arm is the payload-included one. - Accessors funnel through two private helpers, block() and contents(), not one. Both arms carry a block, but only the included arm has an envelope, blobs and proofs. Asking for an envelope when the payload was excluded is a caller error -- it has to be fetched from the producing node -- so those accessors return an error saying so rather than an empty value, which would yield a proposal that cannot be published. The pre-gloas and unknown-version arms are collapsed into one case each, so the two helpers cannot drift. - The accessor set is deliberately eight, not the eighteen the older proposal wrapper carries. Slot and RandaoReveal are what the endpoint's own consistency checks need; the envelope, blobs and proofs are what a caller needs to publish; Value, IsEmpty and String are house form. Anything else is reachable from Block() and was left out rather than mirrored speculatively. - The JSON test compares re-marshalled bytes instead of using require.Equal on the structs. Struct equality fails here for a reason that is not this codec's: the nested gloas.ExecutionPayload codec normalises a nil Transactions, ExtraData or BlockAccessList to an empty slice on the way back in, so the assertion would pin another package's conventions. Byte equality keeps the assertion sensitive to what matters, which the mutation testing below confirms. Files changed: - new: api/v1/gloas/blockcontents.go, blockcontents_json.go, blockcontents_yaml.go, blockcontents_ssz.go (generated), blockcontents_test.go - new: api/versionedepbsproposal.go, api/versionedepbsproposal_test.go, api/epbsproposalopts.go - modified: api/v1/gloas/generate.yaml (one codegen entry) Verified: go build ./... clean; gosilent test ./... 2334 green, up from 2306; custom-gcl 0 issues repo-wide; go vet and gofmt clean on all eight new files; go generate ./api/v1/gloas leaves the pre-existing generated sibling byte-identical. Note .golangci.yml sets run.tests: false, so lint never inspects the test files -- go vet and gofmt are what cover them. 21 tracked files under api/ are gofmt-dirty at HEAD; none of them are touched here. Coverage is mutation-proven rather than assumed. Swallowing the RawJSON presence error fails exactly the four missing-field subtests and nothing else; dropping the blobs unmarshal fails both the dedicated blob test and the round-trip byte comparison, which is the evidence that comparing bytes did not weaken the assertion. Both were reverted and the suite confirmed green after. One test defect was caught by running the RED rather than by review: the blobs-included subtest passed against a stub returning nil, because require.Empty is satisfied by nil. It asserts a length now. A vacuous assertion that looks like coverage is the failure mode this repository's hallucination log keeps recording, and it is only visible if you read which subtests fail during RED. Blockers / notes for next iteration: - The endpoint, its opts validation, the validator-side envelope GET, the provider interfaces and the multi/mock/testclients parity are all still to do, as are the two mechanical gloas arms on signedbeaconblock and submitproposal. Nothing here is reachable from a client yet: these types have no caller, so a mistake in them is currently invisible outside their own tests. - submitproposal's SSZ path marshals with the plain generated codec, no dynssz. A gloas block reaches preset-sized fields -- SyncAggregate's committee bits are 512 entries at mainnet and 32 at minimal -- so the gloas arm may need the request-scoped dynssz codec that the newer submitters use. This should be settled empirically against the minimal-preset devnet, not by reasoning, and if it turns out to affect every fork equally then it is a pre-existing limitation to capture separately rather than to fix inside the gloas arm. - The blinded surface is deliberately untouched and must stay that way: there is no Gloas.SignedBlindedBeaconBlock schema, and two of the three blinded endpoints have been removed from the spec entirely. - No reviewer pass has run over this yet. It was committed as a verified subset to hand over working code, not as a finished slice. --- api/epbsproposalopts.go | 58 +++++++ api/v1/gloas/blockcontents.go | 43 +++++ api/v1/gloas/blockcontents_json.go | 67 ++++++++ api/v1/gloas/blockcontents_ssz.go | 246 ++++++++++++++++++++++++++++ api/v1/gloas/blockcontents_test.go | 204 +++++++++++++++++++++++ api/v1/gloas/blockcontents_yaml.go | 63 +++++++ api/v1/gloas/generate.yaml | 2 + api/versionedepbsproposal.go | 204 +++++++++++++++++++++++ api/versionedepbsproposal_test.go | 253 +++++++++++++++++++++++++++++ 9 files changed, 1140 insertions(+) create mode 100644 api/epbsproposalopts.go create mode 100644 api/v1/gloas/blockcontents.go create mode 100644 api/v1/gloas/blockcontents_json.go create mode 100644 api/v1/gloas/blockcontents_ssz.go create mode 100644 api/v1/gloas/blockcontents_test.go create mode 100644 api/v1/gloas/blockcontents_yaml.go create mode 100644 api/versionedepbsproposal.go create mode 100644 api/versionedepbsproposal_test.go diff --git a/api/epbsproposalopts.go b/api/epbsproposalopts.go new file mode 100644 index 000000000..09dbc0d6e --- /dev/null +++ b/api/epbsproposalopts.go @@ -0,0 +1,58 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// 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 + // IncludePayload selects whether a self-built proposal carries its execution + // payload envelope and blobs. + // + // True is stateless: 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. + // + // 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(). + // + // 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 + // BuilderBoostFactor is the relative weight of the builder payload versus a locally-produced + // payload, as per https://ethereum.github.io/beacon-APIs/#/Validator/produceBlockV4 + // This is optional; if not supplied it will use the default value of 100. + BuilderBoostFactor *uint64 +} diff --git a/api/v1/gloas/blockcontents.go b/api/v1/gloas/blockcontents.go new file mode 100644 index 000000000..287359d5c --- /dev/null +++ b/api/v1/gloas/blockcontents.go @@ -0,0 +1,43 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" +) + +// BlockContents is the block-production response body for a self-built Gloas +// block: the beacon block together with the execution payload envelope, blobs +// and KZG proofs that the caller needs to publish it. +type BlockContents struct { + Block *gloas.BeaconBlock + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope + + KZGProofs []deneb.KZGProof `dynssz-max:"FIELD_ELEMENTS_PER_EXT_BLOB*MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"33554432" ssz-size:"?,48"` + Blobs []deneb.Blob `dynssz-max:"MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"4096" ssz-size:"?,131072"` +} + +// String returns a string version of the structure. +func (b *BlockContents) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/blockcontents_json.go b/api/v1/gloas/blockcontents_json.go new file mode 100644 index 000000000..d88ec5f88 --- /dev/null +++ b/api/v1/gloas/blockcontents_json.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 gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/pkg/errors" +) + +// blockContentsJSON is the spec representation of the struct. +type blockContentsJSON struct { + Block *gloas.BeaconBlock `json:"block"` + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope `json:"execution_payload_envelope"` + KZGProofs []deneb.KZGProof `json:"kzg_proofs"` + Blobs []deneb.Blob `json:"blobs"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BlockContents) MarshalJSON() ([]byte, error) { + return json.Marshal(&blockContentsJSON{ + Block: b.Block, + ExecutionPayloadEnvelope: b.ExecutionPayloadEnvelope, + KZGProofs: b.KZGProofs, + Blobs: b.Blobs, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BlockContents) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&blockContentsJSON{}, input) + if err != nil { + return err + } + + if err := json.Unmarshal(raw["block"], &b.Block); err != nil { + return errors.Wrap(err, "block") + } + + if err := json.Unmarshal(raw["execution_payload_envelope"], &b.ExecutionPayloadEnvelope); err != nil { + return errors.Wrap(err, "execution_payload_envelope") + } + + if err := json.Unmarshal(raw["kzg_proofs"], &b.KZGProofs); err != nil { + return errors.Wrap(err, "kzg_proofs") + } + + 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 000000000..1b90ef8f3 --- /dev/null +++ b/api/v1/gloas/blockcontents_ssz.go @@ -0,0 +1,246 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 08983a4ebad0aa31525d28e8b9f233363a30a7d5b699b3ccbadd08ea7228d3d6 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BlockContents](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BlockContents to SSZ-encoded bytes. +func (t *BlockContents) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BlockContents to SSZ-encoded bytes, appending to the provided buffer. +func (t *BlockContents) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BlockContents) + } + dstlen := len(dst) + // Offset Field #0 'Block' + // Offset Field #1 'ExecutionPayloadEnvelope' + // Offset Field #2 'KZGProofs' + // Offset Field #3 'Blobs' + dst = append(dst, zeroBytes[:16]...) + { // Dynamic Field #0 'Block' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Block + if t == nil { + t = new(gloas.BeaconBlock) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Block") + } + } + { // Dynamic Field #1 'ExecutionPayloadEnvelope' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.ExecutionPayloadEnvelope + if t == nil { + t = new(gloas.ExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Dynamic Field #2 'KZGProofs' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.KZGProofs + vlen := len(t) + if vlen > 33554432 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #3 'Blobs' + binary.LittleEndian.PutUint32(dst[dstlen+12:], uint32(len(dst)-dstlen)) + t := t.Blobs + vlen := len(t) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BlockContents from SSZ-encoded bytes. +func (t *BlockContents) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 16 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 16) + } + // Field #0 'Block' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 16 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 16), "Block:o") + } + // Field #1 'ExecutionPayloadEnvelope' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "ExecutionPayloadEnvelope:o") + } + // Field #2 'KZGProofs' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "KZGProofs:o") + } + // Field #3 'Blobs' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[12:16])) + if offset3 < offset2 || offset3 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset3, offset2, buflen), "Blobs:o") + } + { // Field #0 'Block' (dynamic) + buf := buf[offset0:offset1] + if t.Block == nil { + t.Block = new(gloas.BeaconBlock) + } + if err = t.Block.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Block") + } + } + { // Field #1 'ExecutionPayloadEnvelope' (dynamic) + buf := buf[offset1:offset2] + if t.ExecutionPayloadEnvelope == nil { + t.ExecutionPayloadEnvelope = new(gloas.ExecutionPayloadEnvelope) + } + if err = t.ExecutionPayloadEnvelope.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Field #2 'KZGProofs' (dynamic) + buf := buf[offset2:offset3] + val1 := t.KZGProofs + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "KZGProofs") + } + if itemCount > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 33554432), "KZGProofs") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.KZGProofs = val1 + } + { // Field #3 'Blobs' (dynamic) + buf := buf[offset3:] + val2 := t.Blobs + itemCount := len(buf) / 131072 + if len(buf)%131072 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 131072), "Blobs") + } + if itemCount > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 4096), "Blobs") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + sszutils.UnmarshalFixedBytesSlice(val2[:itemCount], buf) + t.Blobs = val2 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BlockContents. +func (t *BlockContents) SizeSSZ() (size int) { + if t == nil { + t = new(BlockContents) + } + // Field #0 'Block' offset (4 bytes) + // Field #1 'ExecutionPayloadEnvelope' offset (4 bytes) + // Field #2 'KZGProofs' offset (4 bytes) + // Field #3 'Blobs' offset (4 bytes) + size += 16 + { // Dynamic field #0 'Block' + size += t.Block.SizeSSZ() + } + { // Dynamic field #1 'ExecutionPayloadEnvelope' + size += t.ExecutionPayloadEnvelope.SizeSSZ() + } + { // Dynamic field #2 'KZGProofs' + size += len(t.KZGProofs) * 48 + } + { // Dynamic field #3 'Blobs' + size += len(t.Blobs) * 131072 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BlockContents. +func (t *BlockContents) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BlockContents using the given hash walker. +func (t *BlockContents) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BlockContents) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Block' + t := t.Block + if t == nil { + t = new(gloas.BeaconBlock) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Block") + } + } + { // Field #1 'ExecutionPayloadEnvelope' + t := t.ExecutionPayloadEnvelope + if t == nil { + t = new(gloas.ExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Field #2 'KZGProofs' + t := t.KZGProofs + vlen := uint64(len(t)) + if vlen > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(33554432, vlen, 32)) + } + { // Field #3 'Blobs' + t := t.Blobs + vlen := uint64(len(t)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:131072]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 32)) + } + hh.Merkleize(idx) + return nil +} diff --git a/api/v1/gloas/blockcontents_test.go b/api/v1/gloas/blockcontents_test.go new file mode 100644 index 000000000..01ba31b33 --- /dev/null +++ b/api/v1/gloas/blockcontents_test.go @@ -0,0 +1,204 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// validBeaconBlock returns a block populated just far enough for its own JSON +// marshaler to run: every pointer the marshaler dereferences is non-nil. +func validBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// validExecutionPayloadEnvelope returns an envelope whose payload carries a +// non-nil BaseFeePerGas, which the payload's own marshaler dereferences. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(0)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + } +} + +// validBlockContents returns block contents carrying one KZG proof and no +// blobs. Blobs are left empty deliberately: deneb.Blob is a 128KiB array, so a +// populated one turns any require.Equal failure into a 256KiB hex diff. The +// element path for blobs is covered separately by +// TestBlockContentsJSONPreservesBlobs. +func validBlockContents() *apiv1gloas.BlockContents { + return &apiv1gloas.BlockContents{ + Block: validBeaconBlock(), + ExecutionPayloadEnvelope: validExecutionPayloadEnvelope(), + KZGProofs: []deneb.KZGProof{{0x01, 0x02, 0x03}}, + Blobs: []deneb.Blob{}, + } +} + +// TestBlockContentsJSON verifies that BlockContents marshals to the four field +// names the beacon-APIs Gloas.BlockContents schema requires, and that a +// populated value survives a JSON round trip. The schema names all four in +// snake_case; Go's default marshaler would emit the exported field names +// instead, so the key assertions fail against an uncodec'd struct. +func TestBlockContentsJSON(t *testing.T) { + contents := validBlockContents() + + data, err := json.Marshal(contents) + require.NoError(t, err) + + for _, key := range []string{ + `"block":`, + `"execution_payload_envelope":`, + `"kzg_proofs":`, + `"blobs":`, + } { + require.Contains(t, string(data), key) + } + + var decoded apiv1gloas.BlockContents + require.NoError(t, json.Unmarshal(data, &decoded)) + + // Compare on the wire rather than with require.Equal on the structs: the + // nested gloas.ExecutionPayload codec normalises a nil Transactions, + // ExtraData or BlockAccessList to an empty slice on the way back in, so + // struct equality would assert that package's conventions rather than this + // codec's behaviour. Byte equality still catches the failure that matters — + // drop any one field from UnmarshalJSON and its key re-marshals as null. + reencoded, err := json.Marshal(&decoded) + require.NoError(t, err) + require.Equal(t, string(data), string(reencoded)) + + // The fields this type owns directly must survive intact. + require.Equal(t, contents.KZGProofs, decoded.KZGProofs) + require.Equal(t, contents.Blobs, decoded.Blobs) +} + +// TestBlockContentsSSZ verifies that BlockContents round-trips through SSZ. The +// generated codec is what the block-production endpoint uses when a node answers +// with application/octet-stream, so an absent or misordered codec would surface +// only as an undecodable proposal. +func TestBlockContentsSSZ(t *testing.T) { + contents := validBlockContents() + + data, err := contents.MarshalSSZ() + require.NoError(t, err) + require.NotEmpty(t, data) + + var decoded apiv1gloas.BlockContents + require.NoError(t, decoded.UnmarshalSSZ(data)) + + // SSZ carries no field names, so a wrongly ordered or wrongly sized codec + // shows up as a differing hash tree root rather than a decode error. + want, err := contents.HashTreeRoot() + require.NoError(t, err) + got, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + + require.Equal(t, contents.KZGProofs, decoded.KZGProofs) +} + +// TestBlockContentsString verifies that stringifying block contents renders the +// spec's field names. goccy/go-yaml would otherwise lower-case the Go field +// names, emitting executionpayloadenvelope rather than the spec's +// execution_payload_envelope, so this pins the hand-written YAML marshaler. +func TestBlockContentsString(t *testing.T) { + var str string + require.NotPanics(t, func() { + str = validBlockContents().String() + }) + + require.Contains(t, str, "execution_payload_envelope:") + require.Contains(t, str, "kzg_proofs:") +} + +// TestBlockContentsJSONMissingField verifies that every one of the four fields +// the schema marks required is rejected when absent. encoding/json ignores +// unknown keys and leaves absent ones at their zero value, so without the +// presence check a body missing its blobs would decode as success and hand the +// caller contents it cannot publish. +func TestBlockContentsJSONMissingField(t *testing.T) { + tests := []struct { + name string + omitKey string + err string + }{ + {name: "Block", omitKey: "block", err: "block: missing"}, + { + name: "ExecutionPayloadEnvelope", + omitKey: "execution_payload_envelope", + err: "execution_payload_envelope: missing", + }, + {name: "KZGProofs", omitKey: "kzg_proofs", err: "kzg_proofs: missing"}, + {name: "Blobs", omitKey: "blobs", err: "blobs: missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := json.Marshal(validBlockContents()) + require.NoError(t, err) + + var generic map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &generic)) + delete(generic, test.omitKey) + + pruned, err := json.Marshal(generic) + require.NoError(t, err) + + var decoded apiv1gloas.BlockContents + require.EqualError(t, decoded.UnmarshalJSON(pruned), test.err) + }) + } +} + +// 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 000000000..bdfb524fe --- /dev/null +++ b/api/v1/gloas/blockcontents_yaml.go @@ -0,0 +1,63 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// blockContentsYAML is the spec representation of the struct. +type blockContentsYAML struct { + Block *gloas.BeaconBlock `yaml:"block"` + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope `yaml:"execution_payload_envelope"` + KZGProofs []deneb.KZGProof `yaml:"kzg_proofs"` + Blobs []deneb.Blob `yaml:"blobs"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BlockContents) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&blockContentsYAML{ + Block: b.Block, + ExecutionPayloadEnvelope: b.ExecutionPayloadEnvelope, + KZGProofs: b.KZGProofs, + Blobs: b.Blobs, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BlockContents) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var unmarshaled blockContentsJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/api/v1/gloas/generate.yaml b/api/v1/gloas/generate.yaml index afca147b0..b37fd9a92 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/versionedepbsproposal.go b/api/versionedepbsproposal.go new file mode 100644 index 000000000..f1abdba1a --- /dev/null +++ b/api/versionedepbsproposal.go @@ -0,0 +1,204 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "errors" + "fmt" + "math/big" + + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedEPBSProposal contains a versioned ePBS block proposal, as produced by +// the gloas-onwards block production endpoint. +type VersionedEPBSProposal struct { + Version spec.DataVersion + // ExecutionPayloadIncluded selects which arm below carries the proposal. + ExecutionPayloadIncluded bool + 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 +} + +// 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 +} + +// 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 +} + +// 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: the consensus rewards plus the +// execution payload value. Both components are populated from response headers +// that a node may omit, so both are treated as zero when absent. +func (v *VersionedEPBSProposal) Value() *big.Int { + value := big.NewInt(0) + + if v.ConsensusValue != nil { + value = value.Add(value, v.ConsensusValue) + } + + if v.ExecutionValue != nil { + value = value.Add(value, v.ExecutionValue) + } + + return value +} + +// 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, errors.New("unknown version") + } +} + +// 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, errors.New("unknown version") + } +} diff --git a/api/versionedepbsproposal_test.go b/api/versionedepbsproposal_test.go new file mode 100644 index 000000000..5737f2427 --- /dev/null +++ b/api/versionedepbsproposal_test.go @@ -0,0 +1,253 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api_test + +import ( + "math/big" + "testing" + + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// TestVersionedEPBSProposalSlot verifies that the slot is read from whichever +// arm the execution_payload_included flag selects, and that every way of +// arriving without a block is an error rather than slot 0. Reporting slot 0 for +// an absent block would let a caller sign a proposal for the wrong slot. +func TestVersionedEPBSProposalSlot(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + expected phase0.Slot + err string + }{ + { + name: "PayloadExcluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{Slot: 42}, + }, + expected: 42, + }, + { + name: "PayloadIncluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{Slot: 43}, + }, + }, + expected: 43, + }, + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + // Contents present but its block absent: the flag says the payload + // travelled with the block, so a missing block is still an error. + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unknown 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) + } + }) + } +} + +// 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") +} + +// 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 two reward components are summed +// nil-safely. Both 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", expected: big.NewInt(0)}, + {name: "ConsensusOnly", consensus: big.NewInt(5), expected: big.NewInt(5)}, + {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()) +} From 801d5c56f30f54b70afa4a9b7ada4891baa488bb Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 30 Jul 2026 13:19:08 +0200 Subject: [PATCH 16/37] add the ePBS block-production transport: v4 proposals and the envelope GET Completes the Gloas block-production surface on top of the types landed previously. Two new endpoints plus the two mechanical arms on existing ones, so a validator can now produce, publish and retrieve a post-Gloas block end to end. None of this is a port. The ethpandaops fork has no v4 support on any branch, so every wire detail was read from the beacon-APIs spec at commit 4b4d89a -- still the tip, re-checked rather than assumed, since drift on this endpoint had already invalidated one earlier assumption -- and then exercised against a running node. Verifying against a real node rather than the spec alone is what turned up the things that mattered: - prysm omits execution_payload_value entirely, both the body field and the Eth-Execution-Payload-Value header, although the spec marks it required. Added by beacon-APIs #631 two days before this work; prysm predates it. Both value components are therefore treated as zero when absent rather than demanded. - prysm defaults include_payload to true when the parameter is absent, despite the spec marking it required with no default. Found by mutation: dropping the parameter from the query fails only the payload-excluded tests. So a caller who left it unset would silently get whichever mode its node preferred, which is the argument for the pointer type arriving from the opposite direction than expected. - prysm serves the removed blinded shape from the validator envelope GET, payload_root where the spec requires payload. Reproducible across a 40-sample probe on two of three prysm nodes. Our decoder rejects it, correctly; that captured body is now the fixture pinning the rejection. Key decisions: - The SSZ arms decode through s.dynSSZForRequest, not the generated codec, and this was settled by measurement rather than argument. Against real minimal-preset bytes the generated codec fails with "first offset 336 does not match expected 396": a gloas body holds a preset-sized sync committee bitvector, 64 bytes at mainnet against 4 at minimal, and SSZ addresses variable-size fields by offsets stored after it, so every offset shifts by exactly 60. The error names ProposerSlashings, which is merely the first variable field validated -- a misleading name worth knowing about. An in-process test reproduces the same failure by sizing its fixture from the node's spec, so the requirement is pinned without needing the node's block production. - The envelope's SSZ arm also uses the dynamic codec, but for a weaker reason, stated as such in the code. Measured, that container encodes byte-identically at both presets and the generated codec reads minimal bytes fine, so nothing here proves the dynamic one necessary. It is kept because it cannot be wrong at a preset this code has not run against, and the endpoint is called once per proposal. The comment says that rather than claiming a requirement. - The 404 sentinel joins the api.Error rather than replacing it. This looked like a free simplification and was not: multi.doCall decides whether a node is broken by unwrapping to an api.Error and reading its status, so a bare sentinel turned the answer every non-producing node gives into "the whole set is failing". Demonstrated with four real clients against the devnet -- one call left 0 of 4 active; with the join, 4 of 4. Joining also keeps the response body, which is the only way to tell an empty cache from a node that does not serve the route at all, both being 404s. Nimbus does not serve it, so that is not hypothetical. - What the node returns is checked against what was asked for, in one helper per endpoint. Slot, payload-inclusion mode and RANDAO reveal for the proposal; the beacon block root for the envelope. None of these can be provoked live -- a node that honours the request never disagrees with it -- so each is tested in process, and separately a pair of tests points the service at a deliberately disagreeing local server to prove the guards are reachable at all. Deleting either call site fails those and nothing else; without them an unwired guard leaves the suite green. - Value headers are bounded before parsing. big.Int.SetString is quadratic in its input, the length is the node's to choose, and the parse happens after the request's own deadline is released: a 9MiB header measured 75 seconds of CPU on the one path with a slot to meet, against 56 microseconds once bounded. Negative values are refused too, since Value() would otherwise subtract them and make a proposal look cheaper than a rival's. The same flaw is left untouched in proposal.go, being pre-existing and outside this change, and is captured as follow-up work -- it is the endpoint in production use, so it matters more than the instance fixed here. - submitproposal's gloas body is a plain SignedBeaconBlock, not a contents wrapper. Post-Gloas the blobs travel in the envelope, published separately. AssertPresent also refuses a gloas proposal marked blinded outright rather than ignoring the flag, which would publish a plain block while the caller believed otherwise. - VersionedSignedProposal's Slot, ProposerIndex, String and assertMessagePresent gained gloas arms as well as AssertPresent. Adding the field without them left a populated proposal reporting its own fork as unsupported; nothing in this repo calls those accessors, so it would have surfaced only in a consumer. - providerparity_test.go grows by two blocks rather than a file, per its own note. Verified it still bites: renaming mock's EPBSProposal breaks the build. Files changed: - new: http/epbsproposal.go, http/executionpayloadenvelope.go (+5 test files) - new: api/executionpayloadenvelopeopts.go - new: mock/{epbsproposal,executionpayloadenvelope}.go (+1 test), multi/{epbsproposal,executionpayloadenvelope}.go - modified: http/signedbeaconblock.go and http/submitproposal.go (gloas arms), api/versionedsignedproposal.go (Gloas field + 5 arms), api/epbsproposalopts.go (corrected a doc claim), service.go (2 provider interfaces), errors.go (1 sentinel), mock/service.go (2 hooks), testclients/{erroring,sleepy}.go, providerparity_test.go, http/staticsszdecode_internal_test.go 28 files, 2778 insertions, 3 deletions. Verified: go build ./... clean; gosilent test ./... 2338 green, up from 2334; -race clean on api, mock, multi and the root package; custom-gcl 0 issues repo-wide on a cold cache; go vet and gofmt clean on all 28 files. Note the lint gate is ./custom-gcl, not golangci-lint, which exits 3 here because the config references plugins it does not carry. Against the live devnet the full http suite was run before and after, from a detached worktree at the previous commit so the comparison is against real baseline behaviour rather than a stash. The three TestSignedBeaconBlock failures are fixed. Three TestBlobs/TestBlobsSidecars subtests newly fail, and they are worth understanding rather than dismissing: those tests fetch the head block first and require.NoError it, which aborted them at setup on a gloas chain, so the parent looked red while its version gate and every subtest below were unreachable. They now run for the first time here and fail on their own fixture assumption, that the head block carries blobs -- it does not, and the endpoints correctly return an empty list. Captured as follow-up work. TestBeaconBlockRootTimeout flips on the unchanged baseline too. Coverage is mutation-proven, 25 mutations in all, each failing exactly its own tests. The instructive ones: swallowing the ParseBool error fails only the malformed-header test, which is the case proposal.go's EqualFold form gets wrong; substituting the generated codec fails the preset test and both live SSZ tests; transposing the SSZ arms fails four; reading the JSON flag from the header fails all eleven JSON tests, which is what proves the body is genuinely its source; and sending the wrong consensus-version header fails only live, a class no in-process test can reach. Two mutations survived a first pass and both were real gaps: the payload-inclusion check had no test, and the mutation harness itself under-reported by matching only indented failures, hiding top-level ones. Five reviewers ran. Security demonstrated two high findings with working exploits -- the header parse and the client-rotation drain -- and re-verified both fixes by re-running its own demonstrations. Efficiency benchmarked the decode paths. Spec caught a doc comment inherited from the v3 options struct claiming a default this endpoint deliberately does not apply. Two reviewers independently reproduced the rotation drain, which is what made it credible. Blockers / notes for next iteration: - The include_payload=false round trip cannot be completed end to end, and this is the one acceptance criterion left unticked rather than worked around. Retrieving the cached envelope needs a node that serves the spec's shape; prysm serves the deleted blinded one, nimbus does not implement the endpoint or v4 at all, and grandine implements v4 but was partitioned off the chain throughout. Tolerating payload_root would mean re-introducing a container the spec removed, against the decision that keeps the blinded surface frozen. - submitproposal's SSZ path is mainnet-preset only, and silently so: the generated codec zero-pads a short sync committee bitvector rather than refusing it, so a minimal-preset block marshals cleanly and the node rejects the bytes. Verified identical for electra and gloas, so it is fork-independent and left alone here, with the arm mirroring its siblings and a comment pointing at the tracked defect. The live submit test therefore goes through JSON. - Retrieving the envelope through a multi-client returns nothing when the first active client is not the producing node, because doCall has no "try the next one without deactivating it" outcome. The alternative was the rotation drain, so this is the better of the two available behaviours, not a good one. - The devnet's health script reports UNHEALTHY on a working chain when one node partitions, deriving head epoch and missed-slot rate from the wrong node. It said destroy and relaunch; the four-node cluster was finalizing normally. Do not act on that verdict without checking the reference node directly. - No http/ test can gate CI, since TestMain returns without calling m.Run() unless HTTP_ADDRESS is set. Almost everything added here is therefore invisible to ordinary CI; the mock, api and root-package tests are not, which is why the mock's default-response coverage lives in mock/ rather than being asserted through an http/ test. --- api/epbsproposalopts.go | 7 +- api/executionpayloadenvelopeopts.go | 40 ++ api/versionedsignedproposal.go | 38 ++ errors.go | 5 + http/epbsguardwiring_internal_test.go | 132 ++++ http/epbsproposal.go | 379 ++++++++++++ http/epbsproposal_internal_test.go | 569 ++++++++++++++++++ http/epbsproposal_test.go | 162 +++++ http/executionpayloadenvelope.go | 215 +++++++ .../executionpayloadenvelope_internal_test.go | 203 +++++++ ...npayloadenvelope_sentinel_internal_test.go | 66 ++ http/executionpayloadenvelope_test.go | 90 +++ http/signedbeaconblock.go | 16 + http/signedbeaconblock_test.go | 35 ++ http/staticsszdecode_internal_test.go | 23 +- http/submitproposal.go | 13 + http/submitproposal_internal_test.go | 143 +++++ http/submitproposal_test.go | 68 +++ mock/epbsproposal.go | 135 +++++ mock/epbsproposal_test.go | 164 +++++ mock/executionpayloadenvelope.go | 51 ++ mock/service.go | 2 + multi/epbsproposal.go | 48 ++ multi/executionpayloadenvelope.go | 56 ++ providerparity_test.go | 12 + service.go | 35 ++ testclients/erroring.go | 39 ++ testclients/sleepy.go | 35 ++ 28 files changed, 2778 insertions(+), 3 deletions(-) create mode 100644 api/executionpayloadenvelopeopts.go create mode 100644 http/epbsguardwiring_internal_test.go create mode 100644 http/epbsproposal.go create mode 100644 http/epbsproposal_internal_test.go create mode 100644 http/epbsproposal_test.go create mode 100644 http/executionpayloadenvelope.go create mode 100644 http/executionpayloadenvelope_internal_test.go create mode 100644 http/executionpayloadenvelope_sentinel_internal_test.go create mode 100644 http/executionpayloadenvelope_test.go create mode 100644 http/submitproposal_internal_test.go create mode 100644 mock/epbsproposal.go create mode 100644 mock/epbsproposal_test.go create mode 100644 mock/executionpayloadenvelope.go create mode 100644 multi/epbsproposal.go create mode 100644 multi/executionpayloadenvelope.go diff --git a/api/epbsproposalopts.go b/api/epbsproposalopts.go index 09dbc0d6e..701f12d5b 100644 --- a/api/epbsproposalopts.go +++ b/api/epbsproposalopts.go @@ -53,6 +53,11 @@ type EPBSProposalOpts struct { IncludePayload *bool // BuilderBoostFactor is the relative weight of the builder payload versus a locally-produced // payload, as per https://ethereum.github.io/beacon-APIs/#/Validator/produceBlockV4 - // This is optional; if not supplied it will use the default value of 100. + // + // This is optional, and when it is not supplied the parameter is left off the + // request entirely, leaving the choice to the node. Note that differs from + // ProposalOpts, which materialises 100 client-side: the spec states no + // default for either endpoint, so sending nothing is what actually leaves the + // decision where the caller left it. BuilderBoostFactor *uint64 } diff --git a/api/executionpayloadenvelopeopts.go b/api/executionpayloadenvelopeopts.go new file mode 100644 index 000000000..56bd01c92 --- /dev/null +++ b/api/executionpayloadenvelopeopts.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// ExecutionPayloadEnvelopeOpts are the options for obtaining the execution +// payload envelope a node cached while producing a block. +// +// This is the other half of asking for a proposal with the payload excluded: the +// node kept the envelope, and this is how the caller retrieves it to sign and +// publish. +type ExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // Slot is the slot for which the envelope was built. + Slot phase0.Slot + // BeaconBlockRoot is the root of the beacon block the envelope commits to. + // + // Passing it makes the lookup re-org resistant: if a re-org happens between + // producing the block and retrieving the envelope, the caller gets nothing + // rather than an envelope committed to a block that is no longer on the + // chain. The node is expected to check the cached envelope against it, and + // the returned envelope's own root is checked against it again here, so a + // node that does not is caught rather than trusted. + BeaconBlockRoot phase0.Root +} diff --git a/api/versionedsignedproposal.go b/api/versionedsignedproposal.go index 96d97be1b..71d078255 100644 --- a/api/versionedsignedproposal.go +++ b/api/versionedsignedproposal.go @@ -26,6 +26,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -47,10 +48,20 @@ type VersionedSignedProposal struct { ElectraBlinded *apiv1electra.SignedBlindedBeaconBlock Fulu *apiv1fulu.SignedBlockContents FuluBlinded *apiv1electra.SignedBlindedBeaconBlock + // Gloas is a plain signed block, 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 here. + // + // There is deliberately no GloasBlinded counterpart. Post-Gloas a proposer + // commits to an execution payload bid rather than to a payload, so there is + // nothing to withhold and no blinded schema to carry. + Gloas *gloas.SignedBeaconBlock } // AssertPresent throws an error if the expected proposal // given the version and blinded fields is not present. +// +//nolint:gocyclo // one arm per fork, and per blinded variant within it func (v *VersionedSignedProposal) AssertPresent() error { switch v.Version { case spec.DataVersionPhase0: @@ -101,6 +112,17 @@ func (v *VersionedSignedProposal) AssertPresent() error { if v.FuluBlinded == nil && v.Blinded { return errors.New("blinded fulu proposal not present") } + case spec.DataVersionGloas: + // No blinded arm to consider: there is no blinded proposal post-Gloas. + // A proposal marked blinded here is a caller mistake rather than a + // second shape to look for, so it is refused outright. + if v.Blinded { + return errors.New("gloas proposals are never blinded") + } + + if v.Gloas == nil { + return errors.New("gloas proposal not present") + } default: return errors.New("unsupported version") } @@ -150,6 +172,9 @@ func (v *VersionedSignedProposal) Slot() (phase0.Slot, error) { } return v.Fulu.SignedBlock.Message.Slot, nil + case spec.DataVersionGloas: + // No blinded arm: there is no blinded proposal post-Gloas. + return v.Gloas.Message.Slot, nil default: return 0, ErrUnsupportedVersion } @@ -196,6 +221,8 @@ func (v *VersionedSignedProposal) ProposerIndex() (phase0.ValidatorIndex, error) } return v.Fulu.SignedBlock.Message.ProposerIndex, nil + case spec.DataVersionGloas: + return v.Gloas.Message.ProposerIndex, nil default: return 0, ErrUnsupportedVersion } @@ -328,6 +355,12 @@ func (v *VersionedSignedProposal) String() string { } return v.Fulu.String() + case spec.DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unsupported version" } @@ -402,6 +435,11 @@ func (v *VersionedSignedProposal) assertMessagePresent() error { return ErrDataMissing } } + case spec.DataVersionGloas: + if v.Gloas == nil || + v.Gloas.Message == nil { + return ErrDataMissing + } default: return ErrUnsupportedVersion } diff --git a/errors.go b/errors.go index 187769d54..811c6e97d 100644 --- a/errors.go +++ b/errors.go @@ -31,4 +31,9 @@ var ( // outcome rather than a failure, but it is reported as an error so that a caller cannot // mistake it for a datum that is safe to sign. ErrNoPayloadAttestationData = errors.New("no payload attestation data available") + // 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") ) diff --git a/http/epbsguardwiring_internal_test.go b/http/epbsguardwiring_internal_test.go new file mode 100644 index 000000000..18093d3b0 --- /dev/null +++ b/http/epbsguardwiring_internal_test.go @@ -0,0 +1,132 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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. +// A service is built against the live node first, so its spec and genesis are +// real and cached, then its base URL is repointed at the local server for the one +// call under test. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// disagreeingService returns a service whose requests are answered by handler, +// and which is otherwise configured from the live node. +func disagreeingService(ctx context.Context, t *testing.T, handler http.HandlerFunc) *Service { + t.Helper() + + address := os.Getenv("HTTP_ADDRESS") + if address == "" { + t.Skip("HTTP_ADDRESS not set") + } + + // Built against the real node so that the spec fetch, the sync assertion and + // the SSZ codec are all genuine; only the endpoint under test is faked. + service, err := New(ctx, WithAddress(address), WithCustomSpecSupport(true), WithEnforceJSON(true)) + require.NoError(t, err) + + s := service.(*Service) + + // Warm the spec cache while the real address is still in place, since the + // fake server does not serve it. + _, err = s.Spec(ctx, &api.SpecOpts{}) + require.NoError(t, err) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + base, err := url.Parse(server.URL) + require.NoError(t, err) + s.base = base + + return s +} + +// TestEPBSProposalRejectsADisagreeingNode verifies that the request-consistency +// guard is reached from EPBSProposal, by asking a node that answers with the +// payload-inclusion mode that was not requested. +func TestEPBSProposalRejectsADisagreeingNode(t *testing.T) { + ctx := context.Background() + + const slot = 60300 + + reveal := phase0.BLSSignature{0x0a, 0x0b, 0x0c} + + // Always answers payload-excluded, whatever was asked for. + s := disagreeingService(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + block := validEPBSBeaconBlock() + block.Slot = slot + block.Body.RANDAOReveal = reveal + + data, err := json.Marshal(block) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + fmt.Fprintf(w, `{"version":"gloas","execution_payload_included":false,"data":%s}`, data) + }) + + includePayload := true + + _, err := s.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: reveal, + IncludePayload: &includePayload, + }) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload included false; expected true") +} + +// 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() + + // Always answers with the fixture's own root, whatever was asked for. + s := disagreeingService(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + data, err := json.Marshal(validExecutionPayloadEnvelope()) + require.NoError(t, err) + + 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 000000000..ae3861385 --- /dev/null +++ b/http/epbsproposal.go @@ -0,0 +1,379 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "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 + +// EPBSProposal fetches a potential ePBS beacon block for signing. +// +// This is the gloas-onwards block production endpoint. Post-Gloas a proposer +// commits to an execution payload bid rather than to a payload, so there is no +// blinded proposal; the axis that replaced it is opts.IncludePayload, which +// decides whether the execution payload envelope travels with the block or +// stays cached on the producing node. +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 + } + + endpoint := fmt.Sprintf("/eth/v4/validator/blocks/%d", opts.Slot) + + httpResponse, err := s.get(ctx, endpoint, query, &opts.Common, true) + if err != nil { + return nil, errors.Join(errors.New("failed to request epbs beacon block proposal"), 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 + } + + return response, nil +} + +// assertEPBSProposalMatchesRequest checks that the proposal a node returned is +// the one that was asked for. Everything checked here is something the caller is +// about to sign or to rely on when publishing, so a node that answers with +// something else must be refused rather than obeyed. +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, + ) + } + + // The payload-inclusion mode must be the one that was asked for. The two are + // not interchangeable — excluded means the block can only be published + // through the node that produced it — so a node answering with the other mode + // has silently changed where the caller is able to publish. Nodes have been + // observed treating the parameter as optional and supplying their own default, + // which is what makes this worth checking rather than assuming. + 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 +} + +// 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" + } + + // Unlike the v3 endpoint, builder_boost_factor is optional here with a + // server-side default, so an unset one is left off the query rather than + // materialised client-side. + if opts.BuilderBoostFactor != nil { + query = fmt.Sprintf("%s&builder_boost_factor=%d", query, *opts.BuilderBoostFactor) + } + + return query, nil +} + +// 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, + ConsensusValue: big.NewInt(0), + ExecutionValue: big.NewInt(0), + } + metadata := metadataFromHeaders(res.headers) + + if err := populateEPBSProposalValuesFromHeaders(proposal, res.headers); err != nil { + return nil, err + } + + switch res.contentType { + case ContentTypeSSZ: + included, err := epbsPayloadIncludedFromHeaders(res.headers) + if err != nil { + return nil, err + } + + proposal.ExecutionPayloadIncluded = included + + // A gloas block carries preset-derived fixed-size fields, so the + // generated codec only reads bodies encoded at the compiled-in mainnet + // preset. The request-scoped codec is built from the spec the node + // reports, which is what makes a non-mainnet preset decodable. + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, err + } + + 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: + included, err := epbsPayloadIncludedFromBody(res.body) + if err != nil { + return nil, err + } + + proposal.ExecutionPayloadIncluded = included + + // The decoder only writes the keys it finds, so seeding it with a typed + // nil pointer is what makes a body with no data key in it + // distinguishable from one carrying a zero-valued datum. + var jsonMetadata map[string]any + + if included { + proposal.GloasContents, jsonMetadata, err = decodeJSONResponse( + bytes.NewReader(res.body), + (*apiv1gloas.BlockContents)(nil), + ) + } else { + proposal.Gloas, jsonMetadata, err = decodeJSONResponse( + bytes.NewReader(res.body), + (*gloas.BeaconBlock)(nil), + ) + } + + 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) + } + + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: metadata, + }, nil +} + +// populateEPBSProposalValuesFromHeaders reads the two value components from the +// response headers. Both are left at the zero they were seeded with when their +// header is absent, which is what a node that predates the addition of +// execution_payload_value to the endpoint sends; a malformed value is an error, +// since a proposal whose worth silently reads as zero would lose an auction it +// should have won. +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") +} + +// epbsPayloadIncludedFromBody reads the payload-inclusion flag from a JSON +// response body. 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 epbsPayloadIncludedFromBody(body []byte) (bool, error) { + var wrapper struct { + ExecutionPayloadIncluded *bool `json:"execution_payload_included"` + } + + if err := json.Unmarshal(body, &wrapper); err != nil { + return false, errors.Join(errors.New("failed to parse epbs proposal response"), err) + } + + if wrapper.ExecutionPayloadIncluded == nil { + return false, errors.New("no execution_payload_included in epbs proposal response") + } + + return *wrapper.ExecutionPayloadIncluded, nil +} diff --git a/http/epbsproposal_internal_test.go b/http/epbsproposal_internal_test.go new file mode 100644 index 000000000..c5dabc772 --- /dev/null +++ b/http/epbsproposal_internal_test.go @@ -0,0 +1,569 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + "os" + "strings" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// validEPBSBeaconBlock returns a gloas beacon block populated just far enough +// for its own JSON marshaler to run: every pointer the marshaler dereferences +// is non-nil. +func validEPBSBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: 60300, + ProposerIndex: 403, + Body: &gloas.BeaconBlockBody{ + RANDAOReveal: phase0.BLSSignature{0x0a, 0x0b, 0x0c}, + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// validEPBSBlockContents returns block contents carrying one KZG proof and no +// blobs. Blobs are left empty deliberately: deneb.Blob is a 128KiB array, so a +// populated one turns any assertion failure into a 256KiB hex diff. The +// payload's BaseFeePerGas must be non-nil, as its own marshaler dereferences it. +func validEPBSBlockContents() *apiv1gloas.BlockContents { + return &apiv1gloas.BlockContents{ + Block: validEPBSBeaconBlock(), + ExecutionPayloadEnvelope: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(7)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + BuilderIndex: 12, + BeaconBlockRoot: phase0.Root{0x0d, 0x0e, 0x0f}, + }, + KZGProofs: []deneb.KZGProof{{0x01, 0x02, 0x03}}, + Blobs: []deneb.Blob{}, + } +} + +// 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()) + }) + + // 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") + }) + + // Both values are absent from a node that predates execution_payload_value + // being added to this endpoint, which is what the validation devnet's client + // sends today. Absence is a zero, not an error: the endpoint is still usable + // without knowing what the block is worth. + t.Run("AbsentValueHeadersAreZero", 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.Equal(t, big.NewInt(0), response.Data.ConsensusValue) + require.Equal(t, big.NewInt(0), response.Data.ExecutionValue) + require.Equal(t, big.NewInt(0), 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. None of them can be provoked against a live node — a node 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. +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 answering with + // the other one has changed that without saying so. Worth checking rather + // than assuming: a node has been observed defaulting the parameter when it is + // absent, despite the spec marking it required. + 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") + }) + + 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() + + address := os.Getenv("HTTP_ADDRESS") + if address == "" { + t.Skip("HTTP_ADDRESS not set") + } + + service, err := New(ctx, WithAddress(address), WithCustomSpecSupport(true)) + require.NoError(t, err) + + return service.(*Service) +} diff --git a/http/epbsproposal_test.go b/http/epbsproposal_test.go new file mode 100644 index 000000000..75a1342d7 --- /dev/null +++ b/http/epbsproposal_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_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/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) + + // The node produces a block for the slot it is about to propose, so the + // target is derived from its head rather than from the wall clock. + slot := headSlot(ctx, t, service) + 1 + + // A service that pins JSON, so the two encodings are both reached. Custom + // spec support is on either way: the validation devnet runs the minimal + // preset, and a gloas block is undecodable against the compiled-in one. + jsonService, err := newTestService(ctx, true, http.WithEnforceJSON(true)) + require.NoError(t, err) + + tests := []struct { + name string + service client.Service + included bool + }{ + {name: "SSZPayloadExcluded", service: service, included: false}, + {name: "SSZPayloadIncluded", service: service, included: true}, + {name: "JSONPayloadExcluded", service: jsonService, included: false}, + {name: "JSONPayloadIncluded", service: jsonService, included: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + includePayload := test.included + infinity := infinitySignature() + + response, err := test.service.(client.EPBSProposalProvider).EPBSProposal(ctx, + &api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: infinity, + IncludePayload: &includePayload, + SkipRandaoVerification: true, + }, + ) + 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) + }) + } +} + +// 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/executionpayloadenvelope.go b/http/executionpayloadenvelope.go new file mode 100644 index 000000000..1598e6880 --- /dev/null +++ b/http/executionpayloadenvelope.go @@ -0,0 +1,215 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "go.opentelemetry.io/otel" +) + +// ExecutionPayloadEnvelope obtains 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 +// producing node kept the envelope, and this collects it so the caller can sign +// and publish it. A node caches only the envelope for the slot it is proposing, +// so anything else — a past slot, a block another node built, a block a re-org +// has displaced — answers ErrNoExecutionPayloadEnvelope. +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 a fetched envelope belongs to the block it +// was asked for. +// +// The block root is the only field the request and the envelope have in common, +// and it is the one that matters: the caller signs this envelope and publishes +// it, so an envelope committed to another block is a payload reveal that cannot +// be published and a missed payload for the slot. Checking it rather than +// trusting the node to have checked it is the stance the block-production +// endpoint already takes on the slot and the RANDAO reveal. +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.get(ctx, endpoint, "", &opts.Common, true) + if err != nil { + return nil, notFoundToSentinel(err) + } + + return httpResponse, nil +} + +// notFoundToSentinel reports a 404 as ErrNoExecutionPayloadEnvelope, and passes +// anything else through untouched. +// +// The endpoint defines a 404 as "nothing cached for this slot and block root", +// which is the normal outcome of asking any node but the one that built the +// block; reported as the raw transport error, a caller cannot tell it apart from +// a genuine fault. +// +// The api.Error is joined to the sentinel rather than replaced by it, so the +// value answers both of the checks made of it. errors.Is finds the sentinel, +// which is what a caller matches on. errors.As still finds the api.Error, which +// matters twice over: a multi-client's failover treats an unwrappable 4xx as the +// node's answer rather than as the node being broken, so without it every client +// in the set is deactivated by the very response documented here as normal; and a +// 404 also comes back from a node that does not implement this route at all, or +// from a proxy filtering it, which the retained body is the only way to tell +// apart from an empty cache. +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 000000000..cb1e35234 --- /dev/null +++ b/http/executionpayloadenvelope_internal_test.go @@ -0,0 +1,203 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// validExecutionPayloadEnvelope returns an envelope populated far enough for its +// own marshalers to run: BaseFeePerGas is dereferenced by the payload's. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(7)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + BuilderIndex: 12, + BeaconBlockRoot: phase0.Root{0x0a, 0x0b}, + ParentBeaconBlockRoot: phase0.Root{0x0c, 0x0d}, + } +} + +// TestExecutionPayloadEnvelopeFromResponse covers the decode half of the +// endpoint. A node caches an envelope for one slot at a time, so the success +// path cannot be provoked on demand against a live node; this is where it, the +// SSZ body and the error paths are exercised. +func TestExecutionPayloadEnvelopeFromResponse(t *testing.T) { + ctx := context.Background() + s := &Service{} + + envelope := validExecutionPayloadEnvelope() + + envelopeJSON, err := json.Marshal(envelope) + require.NoError(t, err) + + jsonBody := fmt.Appendf(nil, `{"version":"gloas","data":%s}`, envelopeJSON) + + t.Run("JSON", func(t *testing.T) { + response, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.False(t, response.Data.IsEmpty()) + + builderIndex, err := response.Data.BuilderIndex() + require.NoError(t, err) + require.Equal(t, gloas.BuilderIndex(12), builderIndex) + + root, err := response.Data.BeaconBlockRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root{0x0a, 0x0b}, root) + + payload, err := response.Data.Payload() + require.NoError(t, err) + require.Equal(t, uint256.NewInt(7), payload.BaseFeePerGas) + }) + + t.Run("SSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + response, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + + // SSZ carries no field names, so a wrongly ordered codec shows up as a + // differing hash tree root rather than a decode error. + want, err := envelope.HashTreeRoot() + require.NoError(t, err) + got, err := response.Data.Gloas.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + }) + + // An envelope without its payload is not a usable envelope: the whole point + // of retrieving one is to publish the payload it carries. The spec marks the + // field required, and the container's own decoder enforces it, which matters + // because a node still on the removed blinded-envelope schema sends a + // payload_root in its place and encoding/json would otherwise ignore the + // unrecognised key and hand back an envelope with no payload at all. + t.Run("JSONWithoutPayload", func(t *testing.T) { + blinded := []byte(`{"version":"gloas","data":{` + + `"payload_root":"0xfd2b34f63745b836c3ce3153439315284de96536d78ceac0f62408c9b1eebfd1",` + + `"execution_requests":{"deposits":[],"withdrawals":[],"consolidations":[],` + + `"builder_deposits":[],"builder_exits":[]},` + + `"builder_index":"18446744073709551615",` + + `"beacon_block_root":"0xea5d1f1b8bd38f4c8f133689dcfc0d333bf5fcf96d791beffef6023cd5146860",` + + `"parent_beacon_block_root":"0x60ee9ee6812eaa4b111e3d37f777eda3b3baf5b530afe7e4c1a34dcf31f156fc"}}`) + + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: blinded, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas JSON execution payload envelope") + require.ErrorContains(t, err, "payload missing") + }) + + t.Run("PreGloasVersion", func(t *testing.T) { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionFulu, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "execution payload envelope not available for version fulu") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) + + // The decoder writes only the keys it finds, so a body with no data key at + // all leaves the seeded nil pointer untouched. Returning success there would + // hand back a wrapper whose every accessor errors. + t.Run("NoDatum", func(t *testing.T) { + for _, body := range []string{ + `{"version":"gloas"}`, + `{"version":"gloas","data":null}`, + } { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(body), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas execution payload envelope in response") + } + }) + + // The block root is the only field the request and the response have in + // common, and it is what binds the envelope to the block the caller proposed. + // A node answering with a different one hands back an envelope that cannot be + // published, so it is refused rather than passed on. + t.Run("BlockRootMismatch", func(t *testing.T) { + wrapped := &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: validExecutionPayloadEnvelope(), + } + + // The fixture's own root, so the matching case must be accepted. + require.NoError(t, assertEnvelopeIsForBlock(wrapped, phase0.Root{0x0a, 0x0b})) + + err := assertEnvelopeIsForBlock(wrapped, phase0.Root{0xff, 0xee}) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload envelope for block") + }) + + t.Run("CorruptSSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + _, err = s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody[:len(sszBody)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas SSZ execution payload envelope") + }) +} diff --git a/http/executionpayloadenvelope_sentinel_internal_test.go b/http/executionpayloadenvelope_sentinel_internal_test.go new file mode 100644 index 000000000..7f7e9eb28 --- /dev/null +++ b/http/executionpayloadenvelope_sentinel_internal_test.go @@ -0,0 +1,66 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadEnvelopeSentinel pins the shape of the error a 404 +// produces, which carries more weight than it looks like it should. +// +// A multi-client's failover decides whether a node is broken by unwrapping the +// error to an *api.Error and reading its status: an unwrappable 4xx is the node's +// answer, anything else is the node failing. This endpoint's 404 is the answer +// every node but the producing one gives, so a sentinel that hides the api.Error +// turns an ordinary call into the whole set looking broken, and the set gets +// deactivated a node at a time. Both properties therefore have to hold at once, +// and neither is implied by the other — which is why they are asserted together +// rather than left to the caller-facing errors.Is check alone. +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 000000000..14c02e700 --- /dev/null +++ b/http/executionpayloadenvelope_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 http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/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) { + // Far enough back to be certain nothing is cached, and still comfortably + // after the fork, which the node rejects separately. + slot := headSlot(ctx, t, service) - 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) { + _, err := provider.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + Slot: 1, + BeaconBlockRoot: phase0.Root{0x01}, + }) + require.Error(t, err) + require.NotErrorIs(t, err, client.ErrNoExecutionPayloadEnvelope) + + var apiErr *api.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, 400, apiErr.StatusCode) + }) +} diff --git a/http/signedbeaconblock.go b/http/signedbeaconblock.go index ce57d6686..f63da23dc 100644 --- a/http/signedbeaconblock.go +++ b/http/signedbeaconblock.go @@ -27,6 +27,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" dynssz "github.com/pk910/dynamic-ssz" ) @@ -179,6 +180,17 @@ func (s *Service) signedBeaconBlockFromSSZ(ctx context.Context, if err != nil { return nil, errors.Join(errors.New("failed to decode fulu signed block contents"), err) } + case spec.DataVersionGloas: + response.Data.Gloas = &gloas.SignedBeaconBlock{} + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + } else { + err = response.Data.Gloas.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode gloas signed beacon block"), err) + } default: return nil, fmt.Errorf("unhandled block version %s", res.consensusVersion) } @@ -224,6 +236,10 @@ func (*Service) signedBeaconBlockFromJSON(res *httpResponse) (*api.Response[*spe response.Data.Fulu, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &electra.SignedBeaconBlock{}, ) + case spec.DataVersionGloas: + response.Data.Gloas, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), + &gloas.SignedBeaconBlock{}, + ) default: return nil, fmt.Errorf("unhandled version %s", res.consensusVersion) } diff --git a/http/signedbeaconblock_test.go b/http/signedbeaconblock_test.go index 2315b64b2..4fdd9ecd6 100644 --- a/http/signedbeaconblock_test.go +++ b/http/signedbeaconblock_test.go @@ -20,9 +20,44 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +// TestSignedBeaconBlockGloas verifies that a block read back from a gloas chain +// lands in the gloas arm and is usable. +// +// TestSignedBeaconBlock below tolerates any api.Error, since the node may not +// hold the block asked for, which also makes it tolerate a version the client +// cannot decode. This asserts the arm directly instead. +func TestSignedBeaconBlockGloas(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + response, err := service.(client.SignedBeaconBlockProvider).SignedBeaconBlock(ctx, + &api.SignedBeaconBlockOpts{Block: "head"}, + ) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.NotNil(t, response.Data.Gloas) + require.NotNil(t, response.Data.Gloas.Message) + + // Post-Gloas the block commits to an execution payload bid rather than + // carrying a payload, so the bid is what must have survived decoding. + require.NotNil(t, response.Data.Gloas.Message.Body.SignedExecutionPayloadBid) + + slot, err := response.Data.Slot() + require.NoError(t, err) + require.NotZero(t, slot) + + root, err := response.Data.Root() + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, root) +} + func TestSignedBeaconBlock(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/http/staticsszdecode_internal_test.go b/http/staticsszdecode_internal_test.go index 207efecc5..5a5c30e67 100644 --- a/http/staticsszdecode_internal_test.go +++ b/http/staticsszdecode_internal_test.go @@ -134,12 +134,22 @@ func TestBeaconStateFromSSZStaticCodec(t *testing.T) { } } +// staticGloasSignedBeaconBlock returns a gloas block at the slot these tests +// assert on. Unlike the earlier forks' containers it has no encodable zero +// value — the sync aggregate and the execution payload bid are pointers the +// marshaler dereferences — so it reuses the fixture that already satisfies that, +// rather than restating it. +func staticGloasSignedBeaconBlock() *gloas.SignedBeaconBlock { + block := validEPBSBeaconBlock() + block.Slot = staticSlot + + return &gloas.SignedBeaconBlock{Message: block} +} + func TestSignedBeaconBlockFromSSZStaticCodec(t *testing.T) { ctx := context.Background() s := &Service{} - // There is no Gloas arm: under ePBS the block carries a signed execution payload - // bid rather than an inline payload, and that endpoint is not yet gloas-enabled. tests := []struct { name string version spec.DataVersion @@ -181,6 +191,15 @@ func TestSignedBeaconBlockFromSSZStaticCodec(t *testing.T) { version: spec.DataVersionFulu, body: mustMarshalSSZ(t, &electra.SignedBeaconBlock{Message: &electra.BeaconBlock{Slot: staticSlot}}), }, + { + // Gloas needs its body populated where the earlier forks do not: the + // container's own marshaler dereferences the sync aggregate and the + // execution payload bid, neither of which has a zero value it can + // encode. + name: "Gloas", + version: spec.DataVersionGloas, + body: mustMarshalSSZ(t, staticGloasSignedBeaconBlock()), + }, } for _, test := range tests { diff --git a/http/submitproposal.go b/http/submitproposal.go index b81e96ad4..45cb99926 100644 --- a/http/submitproposal.go +++ b/http/submitproposal.go @@ -121,6 +121,11 @@ func (*Service) submitProposalJSON(_ context.Context, specJSON, err = json.Marshal(proposal.Electra) case spec.DataVersionFulu: specJSON, err = json.Marshal(proposal.Fulu) + case spec.DataVersionGloas: + // A plain signed block, unlike the contents wrappers Deneb through Fulu + // publish: post-Gloas the blobs travel in the execution payload + // envelope, which is published through its own endpoint. + specJSON, err = json.Marshal(proposal.Gloas) default: err = errors.New("unknown proposal version") } @@ -161,6 +166,14 @@ func (*Service) submitProposalSSZ(_ context.Context, specSSZ, err = proposal.Electra.MarshalSSZ() case spec.DataVersionFulu: specSSZ, err = proposal.Fulu.MarshalSSZ() + case spec.DataVersionGloas: + // Marshaled with the generated codec, as every arm above it is. Note + // that makes this path mainnet-preset only, since a block body holds a + // preset-sized sync committee bitvector: at another preset the codec + // pads it to the mainnet length and the node rejects the result. That + // affects every fork from altair onwards, not just this arm, so it is + // tracked as its own defect rather than worked around here. + specSSZ, err = proposal.Gloas.MarshalSSZ() default: err = errors.New("unknown proposal version") } diff --git a/http/submitproposal_internal_test.go b/http/submitproposal_internal_test.go new file mode 100644 index 000000000..a3c718606 --- /dev/null +++ b/http/submitproposal_internal_test.go @@ -0,0 +1,143 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// 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}, + }, + } +} + +// TestSubmitProposalGloasMarshaling covers the gloas arm of both request-body +// encoders. Without an arm each 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() + s := &Service{} + + t.Run("JSON", func(t *testing.T) { + body, err := s.submitProposalJSON(ctx, gloasSignedProposal()) + require.NoError(t, err) + + // 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, err := s.submitProposalSSZ(ctx, gloasSignedProposal()) + require.NoError(t, err) + 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 encoders switch on Version, so an + // unchecked nil arm marshals as JSON null or panics on the SSZ path. + t.Run("MissingArm", func(t *testing.T) { + proposal := &api.VersionedSignedProposal{Version: spec.DataVersionGloas} + + require.ErrorContains(t, proposal.AssertPresent(), "gloas proposal not present") + + _, err := s.submitProposalJSON(ctx, proposal) + require.ErrorContains(t, err, "gloas proposal not present") + + _, err = s.submitProposalSSZ(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.submitProposalJSON(ctx, proposal) + require.ErrorContains(t, err, "gloas proposals are never blinded") + + _, err = s.submitProposalSSZ(ctx, proposal) + require.ErrorContains(t, err, "gloas proposals are never blinded") + }) + + // 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 51a21d7db..5e843ce73 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,83 @@ import ( apiv1deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" apiv1electra "github.com/attestantio/go-eth2-client/api/v1/electra" apiv1fulu "github.com/attestantio/go-eth2-client/api/v1/fulu" + "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +// TestSubmitProposalGloas verifies that a gloas block produced by the node comes +// back to it through the publish endpoint, in a body the node can read. +// +// The block cannot actually be accepted: it is unsigned, and a node only builds +// for a slot that has not started yet, so by the time it is submitted the node +// refuses it as being from the future. Neither matters here. What is being +// checked is where the refusal comes from — the node, having decoded the block +// and carried it as far as state transition, rather than the client declining to +// encode a version it does not know, or the node rejecting a body it cannot +// parse. Both of those are what a missing or wrong arm would produce. +// +// Only the JSON body is exercised. The SSZ encoder marshals with the generated +// codec, which is mainnet-preset only, and this devnet runs the minimal preset +// (see the note on that arm). +func TestSubmitProposalGloas(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + 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, err := service.(client.EPBSProposalProvider).EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: headSlot(ctx, t, service) + 1, + RandaoReveal: infinity, + IncludePayload: &includePayload, + SkipRandaoVerification: true, + }) + 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, + Signature: phase0.BLSSignature{0x01, 0x02, 0x03}, + }, + }, + }) + 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/mock/epbsproposal.go b/mock/epbsproposal.go new file mode 100644 index 000000000..260cee4dc --- /dev/null +++ b/mock/epbsproposal.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 mock + +import ( + "context" + "math/big" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" +) + +// EPBSProposal fetches an ePBS proposal for signing. +// +// The response follows opts.IncludePayload, since that is the axis a caller is +// most likely to be exercising: with it set the proposal carries the envelope, +// blobs and proofs needed to publish from anywhere, and without it only a block. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if s.EPBSProposalFunc != nil { + return s.EPBSProposalFunc(ctx, opts) + } + + block := mockGloasBeaconBlock(opts) + + proposal := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ConsensusValue: big.NewInt(1), + ExecutionValue: big.NewInt(2), + } + + 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 000000000..d6fb2b372 --- /dev/null +++ b/mock/epbsproposal_test.go @@ -0,0 +1,164 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestEPBSProposal verifies that the mock's default proposal follows the +// payload-inclusion option and that what it hands back can actually be used. +// +// The marshaling assertions are a smoke check that the mock's output is +// serialisable at all, which is easy to break by leaving one of the many +// pointers a gloas block body holds unset. They do not cover BaseFeePerGas: +// the payload's JSON marshaler substitutes a zero for a nil one, so a nil there +// serialises quietly rather than failing. That is asserted directly instead, +// where a consumer reading the field rather than the JSON would trip over it. +func TestEPBSProposal(t *testing.T) { + ctx := context.Background() + + service, err := mock.New(ctx) + require.NoError(t, err) + + includePayload := true + excludePayload := false + + t.Run("PayloadIncluded", func(t *testing.T) { + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 123, + RandaoReveal: phase0.BLSSignature{0x01, 0x02}, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.True(t, response.Data.ExecutionPayloadIncluded) + require.NotNil(t, response.Data.GloasContents) + require.Nil(t, response.Data.Gloas) + + // The proposal must be consistent with what was asked for, since a + // caller's own checks reject it otherwise. + slot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(123), slot) + + reveal, err := response.Data.RandaoReveal() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0x01, 0x02}, reveal) + + // Everything needed to publish from any node must be reachable. + envelope, err := response.Data.ExecutionPayloadEnvelope() + require.NoError(t, err) + require.NotNil(t, envelope.Payload) + require.NotNil(t, envelope.Payload.BaseFeePerGas) + + _, 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") + + 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 000000000..1c4fa2583 --- /dev/null +++ b/mock/executionpayloadenvelope.go @@ -0,0 +1,51 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// ExecutionPayloadEnvelope obtains the cached execution payload envelope for the +// given slot and beacon block root. +// +// The envelope echoes back the requested block root, so a caller checking that +// what it got back belongs to the block it proposed is exercised. A test that +// needs the ErrNoExecutionPayloadEnvelope path — which is what a real node +// answers for any slot but the one it is proposing — should install +// ExecutionPayloadEnvelopeFunc. +func (s *Service) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + if s.ExecutionPayloadEnvelopeFunc != nil { + return s.ExecutionPayloadEnvelopeFunc(ctx, opts) + } + + envelope := mockGloasExecutionPayloadEnvelope() + envelope.BeaconBlockRoot = opts.BeaconBlockRoot + + return &api.Response[*spec.VersionedExecutionPayloadEnvelope]{ + Data: &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: envelope, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/service.go b/mock/service.go index c73b77341..3198a8d30 100644 --- a/mock/service.go +++ b/mock/service.go @@ -61,7 +61,9 @@ type Service struct { 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) diff --git a/multi/epbsproposal.go b/multi/epbsproposal.go new file mode 100644 index 000000000..90efc1049 --- /dev/null +++ b/multi/epbsproposal.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// EPBSProposal fetches an ePBS proposal for signing. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + proposal, err := client.(consensusclient.EPBSProposalProvider).EPBSProposal(ctx, opts) + if err != nil { + return nil, err + } + + return proposal, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*api.VersionedEPBSProposal]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/executionpayloadenvelope.go b/multi/executionpayloadenvelope.go new file mode 100644 index 000000000..6294560e7 --- /dev/null +++ b/multi/executionpayloadenvelope.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 multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// ExecutionPayloadEnvelope obtains the cached execution payload envelope for the +// given slot and beacon block root. +// +// Worth knowing when using this through a multi-client: only the node that +// produced the block holds its envelope, so every other client in the set +// answers ErrNoExecutionPayloadEnvelope. A caller that excluded the payload +// from its proposal should address the producing node directly rather than rely +// on failover here. +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/providerparity_test.go b/providerparity_test.go index a48a6b397..c4ffb6ea9 100644 --- a/providerparity_test.go +++ b/providerparity_test.go @@ -54,4 +54,16 @@ var ( _ client.PayloadAttestationMessagesSubmitter = (*mock.Service)(nil) _ client.PayloadAttestationMessagesSubmitter = (*testclients.Erroring)(nil) _ client.PayloadAttestationMessagesSubmitter = (*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) ) diff --git a/service.go b/service.go index 9459672f2..02807232a 100644 --- a/service.go +++ b/service.go @@ -338,6 +338,41 @@ 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, + ) +} + +// 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 diff --git a/testclients/erroring.go b/testclients/erroring.go index 3351478c1..680f15bcd 100644 --- a/testclients/erroring.go +++ b/testclients/erroring.go @@ -1182,6 +1182,45 @@ func (s *Erroring) PayloadAttestationData(ctx context.Context, return next.PayloadAttestationData(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) +} + // PayloadAttestationPool obtains the payload attestation pool. func (s *Erroring) PayloadAttestationPool(ctx context.Context, opts *api.PayloadAttestationPoolOpts, diff --git a/testclients/sleepy.go b/testclients/sleepy.go index 12a268e3e..97de3eb98 100644 --- a/testclients/sleepy.go +++ b/testclients/sleepy.go @@ -908,6 +908,41 @@ func (s *Sleepy) PayloadAttestationData(ctx context.Context, return next.PayloadAttestationData(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) +} + // PayloadAttestationPool obtains the payload attestation pool. func (s *Sleepy) PayloadAttestationPool(ctx context.Context, opts *api.PayloadAttestationPoolOpts, From 569b6f3b4440efccac6a7612146d581fc9b3cedb Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 09:54:48 +0200 Subject: [PATCH 17/37] add preset-agnostic PTC participation accessors to gloas.PayloadAttestation bitfield.Bitvector512 is a mainnet-fixed type. BitAt guards on `len(b) != 64` and Len() returns the constant 512, so on the minimal preset -- PTC_SIZE=16, a two-byte AggregationBits, which is what the validation devnet runs -- a correctly decoded, correctly marshalable value reads false at every index and SetBitAt discards every write. The SSZ path is sound (dynssz ceils PTC_SIZE/8 to one byte), which is why this survived transport validation: only the Go accessors are wrong, and only at a preset the tests never used. Three additive accessors -- PTCSize, AttestedAt, SetAttestedAt -- plus field documentation. Nothing is retyped, so the decision that keeps these fields mainnet-typed is untouched; there is also nothing to retype to, go-bitfield having no runtime-sized vector and no Bitvector16. Key decisions: - Out-of-range is an error, not a false or a silent no-op. This is the crux rather than a nicety: the defect is not merely that the width check is wrong but that failing it is indistinguishable from an unset bit. A bool-only reader would have reproduced the same defect at a different width. Two mutations demonstrate the bound is load-bearing -- weakening `>=` to `>`, or making PTCSize return 512, both panic with "index out of range [2] with length 2" against a minimal-preset value. - Reads are implemented over the bytes rather than delegated to the length-tolerant BitIndices, and this was settled by measurement, not preference. Delegation was the obvious option -- BitIndices and Count are already width-correct -- but there is no tolerant single-bit reader, so `slices.Contains(bits.BitIndices(), i)` is O(n) with a 4KB make() per call against O(1) and no allocation. Benchmarked: 4.45ns against 799.3ns at mainnet, and 2.8us against 101us for a full 512-position sweep at realistic participation; the naive per-call form is 113x slower, being O(n^2) over the sweep. The success path is 0 allocs. The bit math had to exist for the setter regardless, so sharing it also makes the two directions agree by construction. - The setter cannot delegate at all, and that asymmetry is why this could not have been closed by documentation. bitfield's length-tolerant family -- BitIndices, Count, Bytes -- is all readers; SetBitAt is the only setter and is length-strict. A doc comment can redirect a read; there is nothing to redirect a write to, so constructing a minimal-preset aggregate was not expressible against this type at all. - The tests build their fixtures as hand-written bytes and cross-check against BitIndices/Count as an independent oracle. Deriving fixtures from SetAttestedAt would have asserted only that the code agrees with itself -- it would pass just as happily with MSB-first ordering, and the write would then be invisible to every other reader of these bits. The oracle is the point: it is what makes a wrong bit order fail. - PTCSize documents that the width is the producing node's claim, not a verified fact. Nothing on the JSON path checks aggregation_bits against PTC_SIZE, so the accessors are safe at any width but authoritative at none. Fixing that is tracked separately, with one correction recorded: the planned upper-bound-only check would reject the harmless over-length case (which already fails SSZ and visibly contradicts itself) while accepting the under-length one, which round-trips cleanly and lets a node shrink the committee it reports. A fixed-width vector needs equality. - Value bumped from three exported methods to four documented traps: Shift panics outright on a value shorter than eight bytes -- binary.BigEndian.Uint64 on a 2-byte slice -- so it fails on a legitimate minimal-preset value rather than merely misbehaving. Nothing here calls it; the field comment would otherwise have read as exhaustive guidance while omitting the one method that crashes. Files changed: - new: spec/gloas/payloadattestation_test.go - modified: spec/gloas/payloadattestation.go (3 accessors + shared bound check), spec/gloas/attestation.go (CommitteeBits field doc; notes electra's identical latent trap without sweeping it), spec/versionedpayloadattestation.go (doc pointer on the accessor that hands out the trapped value), spec/versionedpayloadattestation_test.go and http/payloadattestationpool_internal_test.go (call sites converted off BitAt) - modified: api/versionedsignedproposal.go, http/signedbeaconblock.go, http/submitproposal.go (copyright header line only) 9 files, 352 insertions, 12 deletions. Verified: go build ./... clean; gosilent test ./... 2355 green, up from 2338; -race clean on spec and api; go vet and gofmt clean on all nine files; custom-gcl 0 issues repo-wide on a cold cache; go generate ./spec/gloas leaves the generated siblings byte-identical, which is what confirms the struct-tag whitespace change in attestation.go is inert. Note the lint gate is ./custom-gcl, not golangci-lint, which exits 3 here. The three copyright-header bumps are pre-existing branch debt, not fallout: the same three findings reproduce at HEAD in a detached worktree, they are the files earlier gloas-port commits touched, and the rule diffs against origin/master. Without them the gate is not green. Coverage is mutation-proven, five mutations each failing exactly its own tests: MSB-first bit ordering fails both directions at every width; PTCSize returning the mainnet constant and the off-by-one bound both panic; the setter ignoring its flag fails only the clearing assertions; the setter writing byte 0 fails the byte and oracle assertions. The compressed test runner under-reports here, showing "1 failed" where --verbose showed three, so mutation verdicts were read verbatim. The harness also adjudicated a review disagreement: after deleting a test argued to be redundant, all five mutations were still caught, which is what turned "looks subsumed" into evidence. Also verified against a live devnet node, since http/ tests are skipped entirely unless HTTP_ADDRESS is set: the converted call site passes, and fails when AttestedAt is neutered, which is what proves it exercises the accessor rather than merely compiling against it. Blockers / notes for next iteration: - Retrieving participation through spec.VersionedPayloadAttestation still means reaching into .Gloas, since the wrapper has no AttestedAt/PTCSize of its own. That is the shape the decided scope asked for -- accessors on the container, gloas only -- but it means consumers dereference an arm the wrapper would otherwise nil-check for them. A versioned-level pair would remove both that hazard and the need to name the fork type. - electra.Attestation.CommitteeBits carries the same tag and the same latent trap, deliberately noted rather than fixed. It has no broken path today because its two helpers already read through BitIndices, but that is a property of those helpers, not of the type. - The lint gate is not fully deterministic. A reviewer saw a nolintlint finding in spec/fulu at HEAD that does not reproduce on this tree either repo-wide or scoped, cold cache or warm; the file and the config are byte-identical to HEAD, so only cache state differed. Third recorded instance. A single green run remains weak evidence. --- api/versionedsignedproposal.go | 2 +- http/payloadattestationpool_internal_test.go | 11 +- http/signedbeaconblock.go | 2 +- http/submitproposal.go | 2 +- spec/gloas/attestation.go | 13 +- spec/gloas/payloadattestation.go | 74 ++++++ spec/gloas/payloadattestation_test.go | 242 +++++++++++++++++++ spec/versionedpayloadattestation.go | 5 + spec/versionedpayloadattestation_test.go | 13 +- 9 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 spec/gloas/payloadattestation_test.go diff --git a/api/versionedsignedproposal.go b/api/versionedsignedproposal.go index 71d078255..18ebf5dfc 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 diff --git a/http/payloadattestationpool_internal_test.go b/http/payloadattestationpool_internal_test.go index 0098d7c07..752cd9420 100644 --- a/http/payloadattestationpool_internal_test.go +++ b/http/payloadattestationpool_internal_test.go @@ -59,11 +59,14 @@ func TestPayloadAttestationPoolFromResponse(t *testing.T) { require.NoError(t, err) require.Equal(t, expectedSlot, data.Slot) - // Every element must carry the version, not just the first: a loop - // that set it once outside the body would still pass a length check. - aggregationBits, err := response.Data[i].AggregationBits() + // 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, aggregationBits.BitAt(3)) + require.True(t, attested) } }) diff --git a/http/signedbeaconblock.go b/http/signedbeaconblock.go index f63da23dc..5479d19e4 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 diff --git a/http/submitproposal.go b/http/submitproposal.go index 45cb99926..40e624710 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 diff --git a/spec/gloas/attestation.go b/spec/gloas/attestation.go index 145aea17b..0197f0fa6 100644 --- a/spec/gloas/attestation.go +++ b/spec/gloas/attestation.go @@ -29,10 +29,17 @@ import ( // Attestation is the Ethereum 2 attestation structure. type Attestation struct { - AggregationBits bitfield.Bitlist `ssz-index:"0" ssz-type:"progressive-bitlist"` + 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 `dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-index:"3" ssz-size:"8"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` + // CommitteeBits is Bitvector[MAX_COMMITTEES_PER_SLOT], so its width follows the + // preset: 8 bytes at mainnet, 1 on the minimal preset. As with + // PayloadAttestation.AggregationBits, its BitAt, SetBitAt and Len methods are + // hard-coded to the mainnet width and must not be used, and Shift panics on a + // value shorter than eight bytes; CommitteeIndex below stays correct only because + // it reads through the length-tolerant BitIndices. + // electra.Attestation.CommitteeBits carries the same tag and the same trap. + CommitteeBits bitfield.Bitvector64 `dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-index:"3" ssz-size:"8"` } // attestationJSON is a raw representation of the struct. diff --git a/spec/gloas/payloadattestation.go b/spec/gloas/payloadattestation.go index a59843447..c8d7260d5 100644 --- a/spec/gloas/payloadattestation.go +++ b/spec/gloas/payloadattestation.go @@ -23,11 +23,85 @@ import ( // PayloadAttestation represents a payload attestation. type PayloadAttestation struct { + // AggregationBits is Bitvector[PTC_SIZE], so its width follows the preset: 64 + // bytes at mainnet, 2 on the minimal preset. Four of the bitvector's own methods + // are hard-coded to the mainnet width and must not be used: at any other preset + // BitAt returns false for every index of a correctly-sized value, SetBitAt + // silently discards the write, Len reports 512 regardless, and Shift panics + // outright on a value shorter than eight bytes. Use AttestedAt, SetAttestedAt and + // PTCSize instead. BitIndices, Count and Bytes are length-tolerant up to the + // mainnet width; above it they silently ignore the excess and so disagree with + // PTCSize, which a node sending an over-wide value can provoke. AggregationBits bitfield.Bitvector512 `dynssz-size:"PTC_SIZE/8" ssz-index:"0" ssz-size:"64"` Data *PayloadAttestationData `ssz-index:"1"` Signature phase0.BLSSignature `ssz-index:"2"` } +// PTCSize reports how many payload timeliness committee positions the aggregation +// bits cover. Use this rather than AggregationBits.Len(); see AggregationBits. +// +// This is the width of the value as decoded, not a width verified against the chain's +// own PTC_SIZE, so for an attestation decoded from a beacon node it is that node's +// claim rather than an established fact. A caller that needs the distinction must +// check it against the spec itself. +func (p *PayloadAttestation) PTCSize() uint64 { + return uint64(len(p.AggregationBits)) * 8 +} + +// checkPTCIndex errors if the given position falls outside the committee the +// aggregation bits cover. Both accessors share it so the bound and its error can +// never disagree between the read and the write direction. +func (p *PayloadAttestation) checkPTCIndex(ptcIndex uint64) error { + if size := p.PTCSize(); ptcIndex >= size { + return fmt.Errorf("payload timeliness committee index %d out of range for committee of %d", ptcIndex, size) + } + + return nil +} + +// AttestedAt reports whether the payload timeliness committee member at the given +// position attested. The position is an index into the committee returned by +// get_ptc() for the attestation's slot, not a validator index. +// +// Use this rather than AggregationBits.BitAt(), which is mainnet-only; see +// AggregationBits. An index outside the committee is an error here rather than a +// false, so a failed read cannot be mistaken for an unset bit. +func (p *PayloadAttestation) AttestedAt(ptcIndex uint64) (bool, error) { + if err := p.checkPTCIndex(ptcIndex); err != nil { + return false, err + } + + return p.AggregationBits[ptcIndex/8]&(byte(1)<<(ptcIndex%8)) != 0, nil +} + +// SetAttestedAt records participation for the payload timeliness committee member at +// the given position, which is an index into the committee returned by get_ptc() for +// the attestation's slot, not a validator index. +// +// Use this rather than AggregationBits.SetBitAt(), which is mainnet-only; see +// AggregationBits. bitfield has no length-tolerant setter to delegate to, so the bit +// is set here directly, and an index outside the committee is an error rather than a +// dropped write. +// +// The attested bool mirrors the SetBitAt signature this replaces, so a caller passing a +// computed boolean keeps a single call rather than branching over two method names. +// +//nolint:revive // the bool mirrors the signature this replaces +func (p *PayloadAttestation) SetAttestedAt(ptcIndex uint64, attested bool) error { + if err := p.checkPTCIndex(ptcIndex); err != nil { + return err + } + + bit := byte(1) << (ptcIndex % 8) + if attested { + p.AggregationBits[ptcIndex/8] |= bit + } else { + p.AggregationBits[ptcIndex/8] &^= bit + } + + return nil +} + // String returns a string version of the structure. func (p *PayloadAttestation) String() string { data, err := yaml.Marshal(p) diff --git a/spec/gloas/payloadattestation_test.go b/spec/gloas/payloadattestation_test.go new file mode 100644 index 000000000..6a437369b --- /dev/null +++ b/spec/gloas/payloadattestation_test.go @@ -0,0 +1,242 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + "slices" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// The aggregation bits of a payload attestation are Bitvector[PTC_SIZE], so their +// width is preset-dependent: 512 bits (64 bytes) at mainnet, 16 bits (2 bytes) on +// the minimal preset the validation devnet runs. Both widths below are valid, +// correctly-decoded values. +const ( + minimalPTCBytes = 2 + mainnetPTCBytes = 64 +) + +// 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/versionedpayloadattestation.go b/spec/versionedpayloadattestation.go index c82240ae9..bf40144c1 100644 --- a/spec/versionedpayloadattestation.go +++ b/spec/versionedpayloadattestation.go @@ -70,6 +70,11 @@ func (v *VersionedPayloadAttestation) Data() (*gloas.PayloadAttestationData, err // 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 { diff --git a/spec/versionedpayloadattestation_test.go b/spec/versionedpayloadattestation_test.go index 7ba0339e5..5c61cbbd3 100644 --- a/spec/versionedpayloadattestation_test.go +++ b/spec/versionedpayloadattestation_test.go @@ -57,8 +57,17 @@ func TestVersionedPayloadAttestationAccessors(t *testing.T) { t.Run("AggregationBits", func(t *testing.T) { aggregationBits, err := populated.AggregationBits() require.NoError(t, err) - require.True(t, aggregationBits.BitAt(3)) - require.False(t, aggregationBits.BitAt(4)) + 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") From 2bfe4c0364599f252f9093b19fa73eb77a2dad7b Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 11:37:10 +0200 Subject: [PATCH 18/37] fix multi.Events self-recursion and forward every event handler multi.Service.Events built a substituted options struct per client, pointed its handler fields at activeHandler's own methods, and stored that same struct as the thing the wrappers forwarded through. So h.opts.Handler(event) called h.genericHandler -- itself, unboundedly. A Go stack overflow is fatal and unrecoverable, recover() cannot catch it, so it takes the process down. Three defects share that root cause. The struct also copied only Common, not Topics; and the inactive-client path handed the underlying client the caller's raw opts, bypassing active-address filtering entirely. What the bug actually did in production is not what it looks like, and this only came out by running it. Against a real node the recursion never fires: http rejects a subscription naming no topics, so every active client failed to subscribe, was demoted to the inactive list, and was re-subscribed there with the caller's raw opts -- bypassing the wrapper, hence no recursion, and hence no filtering either. Measured against two devnet nodes over 45s, before and after, from a detached worktree at the previous commit: 14 head events across ~7 slots before, every event delivered twice, once per node, unfiltered; 6 across 6 slots after. So the real impact was duplicate unfiltered delivery -- precisely the inconsistency the wrapper exists to prevent -- and the Topics defect was masking the recursion one. The overflow needs a client that accepts a topic-less subscription; the mock does, which is what the unit test uses, and its RED run dies with "fatal error: stack overflow". Key decisions: - Substitution is nil-aware rather than unconditional, and this is load-bearing rather than tidiness. http dispatches per topic through a fallback chain -- specific handler if set, else the generic one -- so installing a wrapper for a handler the caller never supplied would both starve that fallback and call a nil function. substitute returns nil for a nil handler, so the rule lives at one choke point instead of 22 call sites. - One generic substitute[T] rather than 22 typed wrapper methods. This corrects something asserted in the other direction earlier in the work: the handler signatures differ by type, but they share the shape func(context.Context, T), and inference resolves T through each named handler type. Measured efficiency-neutral -- per-call deltas smaller than the run-to-run noise floor of an unchanged control function, 0 allocs/op both ways, and disassembly shows the per-type and shared-shape closure bodies are byte-identical with the generic dictionary written once at construction and never read per call. - Wrappers capture the caller's handler by value rather than reading it back out of the options. Self-reference is then unconstructible rather than guarded against: the handler is evaluated before the closure that would contain it exists, so the reference chain strictly decreases. It also removes a live read of caller-owned memory at event-delivery time, which was a data race for any caller that mutated its options mid-subscription. - clientOpts stays a distinct struct, but not for the reason the first draft of this change claimed. Self-recursion is no longer the hazard; per-client nesting is. One handler is built per client, so wrapping the caller's struct in place would have the second client's wrapper wrap the first's, filtering one event against two addresses at once. Demonstrated: aliasing the struct fails TestEventsFiltersNonPrimaryClient specifically. - Completeness is enforced by the test, not the production code. The wiring is 23 explicit lines; the test walks api.EventsOpts reflectively, so a handler added later joins it automatically and fails until wired. A hand-written list would need the same manual update the production code does, and would therefore miss in exactly the case that already went wrong -- 10 of 23 handlers were never forwarded, including all seven Gloas ones. - The per-handler filter collapsed into one forwards() predicate, which also fixed an unmeasured cost: the old form called log.With()...Logger() on every event, and zerolog's With() allocates a 500-byte context buffer with no level check. Benchmarked at the default level, where Trace is disabled: 512 B/op and ~100ns become 0 B/op and ~5ns. The same defensive clone at the two newActiveHandler call sites is dropped for the same reason -- With() always allocates fresh, so sharing a logger context cannot corrupt it. Files changed: - modified: multi/events.go (the fix; 354 lines to 227, wiring 23 handlers instead of 13), multi/events_test.go (6 tests, one with 23 reflective subtests) 2 files, 371 insertions, 229 deletions. Verified: go build, go vet and gofmt clean; gosilent test ./... 2383 green; -race clean on multi, and clean over -count=20; ./custom-gcl 0 issues repo-wide. Note the lint gate is ./custom-gcl, not golangci-lint, which exits 3 here. Against the live devnet the caller's handlers receive real events at one delivery per slot with two nodes subscribed, and the generic handler is correctly starved for topics carrying a specific one -- which is the only check that exercises the real fallback dispatch, since the mock does not implement it. Coverage is mutation-proven, 9 mutations each failing exactly its own tests: restoring the recursion kills the binary outright; dropping either nil guard fails 24 and 23 tests; aliasing the caller's struct fails only the non-primary filter test; omitting Topics or Common fails only the options test; dropping one handler's wiring fails only that handler's subtest; inverting the filter fails 28; and cross-wiring the one same-signature pair, ExecutionPayload against ExecutionPayloadGossip, fails 3 -- that pair compiles silently, so the test is the only thing standing between it and a nil deref. Blockers / notes for next iteration: - Reviewers found two pre-existing defects that are captured separately rather than fixed here: mock assigns a package-level logger from New() while live mocks read it, which is a data race reproducible at the previous commit; and multi.Events returns nil even when no client subscribed successfully, which is what made this bug invisible from the outside. - Also pre-existing and untouched: the inactive-client poller sleeps 5s without selecting on ctx.Done(), so a cancelled context does not stop it promptly, and Events appends to a slice header copied from s.inactiveClients after releasing the read lock, so concurrent calls can write the same backing-array slot. - One unexplained observation, recorded rather than resolved: a reviewer saw the two tests asserting the primary address fail together in a single verbose run. It did not reproduce in roughly 360 further runs across two machines-worth of attempts, sequential, parallel and under -race, and there is no mechanism -- the rechecking monitor first fires at 30s, well after these tests finish. --- multi/events.go | 331 +++++++++++++------------------------------ multi/events_test.go | 269 +++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 229 deletions(-) diff --git a/multi/events.go b/multi/events.go index 4b75ad15e..7db653e30 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 @@ -22,15 +22,15 @@ import ( consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" - "github.com/attestantio/go-eth2-client/spec" - "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/capella" - "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/rs/zerolog" ) // Events feeds requested events with the given topics to the supplied handler. +// +// The caller must not mutate opts after calling this. Each handler supplied is captured for the +// lifetime of the subscription, so replacing one afterwards has no effect. The topics slice is +// retained by reference, and is read again whenever a client that was not synced at this point +// later comes into service, which can be long after this returns. func (s *Service) Events(ctx context.Context, opts *api.EventsOpts, ) error { @@ -52,29 +52,9 @@ func (s *Service) Events(ctx context.Context, // Call all active clients immediately. 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 { + ah := newActiveHandler(s, log, client.Address(), opts) + + if err := client.(consensusclient.EventsProvider).Events(ctx, ah.clientOpts); err != nil { inactiveClients = append(inactiveClients, client) continue @@ -85,12 +65,8 @@ func (s *Service) Events(ctx context.Context, // 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, - } + ah := newActiveHandler(s, log, inactiveClient.Address(), opts) + go func(c consensusclient.Service, ah *activeHandler) { for { provider, isProvider := c.(consensusclient.NodeSyncingProvider) @@ -115,8 +91,10 @@ func (s *Service) Events(ctx context.Context, } if !syncResponse.Data.IsSyncing { - // Client is now synced, set up the events call. - if err := c.(consensusclient.EventsProvider).Events(ctx, opts); err != nil { + // Client is now synced, set up the events call. This uses the same substituted + // options as an initially-active client, so that events from it are subject to + // the same active-address filtering. + if err := c.(consensusclient.EventsProvider).Events(ctx, ah.clientOpts); err != nil { ah.log.Error(). Str("address", ah.address). Strs("topics", opts.Topics). @@ -140,215 +118,110 @@ 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) -} - -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) -} - -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 - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.BlobSidecarHandler(ctx, data) -} - -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) + // clientOpts are the options handed to the underlying client: the caller's topics and common + // options, with each handler the caller supplied replaced by a wrapper that filters on the + // active address before forwarding to it. This is always a struct of its own and never the + // caller's own options modified in place, because one handler is built per client: wrapping + // in place would have the second client's wrapper wrap the first's, filtering an event + // against two addresses at once. + clientOpts *api.EventsOpts } -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) +// 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, + } + + sub := &api.EventsOpts{ + Common: opts.Common, + Topics: opts.Topics, + } + ah.clientOpts = sub + + // These need no nil check of their own: substitute leaves a handler the caller did not + // supply nil, for the reason given on it. + sub.Handler = substituteGeneric(ah, opts.Handler) + sub.AttestationHandler = substitute(ah, "attestation", opts.AttestationHandler) + sub.AttesterSlashingHandler = substitute(ah, "attester_slashing", opts.AttesterSlashingHandler) + sub.BlobSidecarHandler = substitute(ah, "blob_sidecar", opts.BlobSidecarHandler) + sub.BlockHandler = substitute(ah, "block", opts.BlockHandler) + sub.BlockGossipHandler = substitute(ah, "block_gossip", opts.BlockGossipHandler) + sub.BLSToExecutionChangeHandler = substitute(ah, "bls_to_execution_change", opts.BLSToExecutionChangeHandler) + sub.ChainReorgHandler = substitute(ah, "chain_reorg", opts.ChainReorgHandler) + sub.ContributionAndProofHandler = substitute(ah, "contribution_and_proof", opts.ContributionAndProofHandler) + sub.DataColumnSidecarHandler = substitute(ah, "data_column_sidecar", opts.DataColumnSidecarHandler) + sub.ExecutionPayloadHandler = substitute(ah, "execution_payload", opts.ExecutionPayloadHandler) + sub.ExecutionPayloadAvailableHandler = substitute(ah, "execution_payload_available", opts.ExecutionPayloadAvailableHandler) + sub.ExecutionPayloadBidHandler = substitute(ah, "execution_payload_bid", opts.ExecutionPayloadBidHandler) + sub.ExecutionPayloadGossipHandler = substitute(ah, "execution_payload_gossip", opts.ExecutionPayloadGossipHandler) + sub.FastConfirmationHandler = substitute(ah, "fast_confirmation", opts.FastConfirmationHandler) + sub.FinalizedCheckpointHandler = substitute(ah, "finalized_checkpoint", opts.FinalizedCheckpointHandler) + sub.HeadHandler = substitute(ah, "head", opts.HeadHandler) + sub.PayloadAttestationMessageHandler = substitute(ah, "payload_attestation_message", opts.PayloadAttestationMessageHandler) + sub.PayloadAttributesHandler = substitute(ah, "payload_attributes", opts.PayloadAttributesHandler) + sub.ProposerPreferencesHandler = substitute(ah, "proposer_preferences", opts.ProposerPreferencesHandler) + sub.ProposerSlashingHandler = substitute(ah, "proposer_slashing", opts.ProposerSlashingHandler) + sub.SingleAttestationHandler = substitute(ah, "single_attestation", opts.SingleAttestationHandler) + sub.VoluntaryExitHandler = substitute(ah, "voluntary_exit", opts.VoluntaryExitHandler) + + return ah } -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") +// 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 - } - - log.Trace().Msg("Forwarding due to primary active address") + h.log.Trace(). + Str("address", h.address). + Str("topic", topic). + Bool("forwarding", forwarding). + Msg("Event received") - h.opts.ProposerSlashingHandler(ctx, data) + return forwarding } -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 +// substitute wraps one of the caller's topic handlers so that an event reaches it only when this +// handler's client is the currently active one. The wrapper is what the underlying client is +// given in place of the caller's own handler. A nil handler yields a nil wrapper, leaving the +// field unset, because clients fall back to the generic handler for any topic whose specific +// handler is nil and substituting one the caller did not supply would starve that fallback. +// +// topic is diagnostic only. It names the handler in the trace log and has no bearing on whether +// the event is forwarded, so a wrong one here mislabels a log line rather than misrouting a call. +func substitute[T any](h *activeHandler, topic string, handler func(context.Context, T)) func(context.Context, T) { + if handler == nil { + return nil } - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.SingleAttestationHandler(ctx, data) -} - -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") + return func(ctx context.Context, data T) { + if !h.forwards(topic) { + return + } - // 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 + handler(ctx, data) } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.VoluntaryExitHandler(ctx, data) } -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") - - // 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 +// substituteGeneric is substitute for the caller's generic handler, which carries its own topic +// and takes no context, and so does not fit substitute's shape. +func substituteGeneric(h *activeHandler, handler api.EventHandlerFunc) api.EventHandlerFunc { + if handler == nil { + return nil } - log.Trace().Msg("Forwarding due to primary active address") + return func(event *apiv1.Event) { + if !h.forwards(event.Topic) { + return + } - if h.opts.Handler != nil { - h.opts.Handler(event) + handler(event) } } diff --git a/multi/events_test.go b/multi/events_test.go index ab3f03b6f..3465403cb 100644 --- a/multi/events_test.go +++ b/multi/events_test.go @@ -15,10 +15,14 @@ package multi_test import ( "context" + "reflect" + "sync/atomic" "testing" + "time" consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/multi" "github.com/attestantio/go-eth2-client/testclients" @@ -54,3 +58,268 @@ func TestEvents(t *testing.T) { Topics: []string{"block"}, })) } + +// 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) +} + +// 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") +} + +// 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 handler, and only this handler. + received := 0 + opts := &api.EventsOpts{Topics: []string{"head"}} + 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 handler is substituted. Clients fall back to the generic handler for any + // topic whose specific handler is nil, so substituting one the caller did not supply + // would both starve that fallback and forward to a nil function. + for _, other := range handlers { + if other.Name == handler.Name { + 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 +} From 6a747d228abe15dfd07649ee40d2a0774c7ef819 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 12:46:00 +0200 Subject: [PATCH 19/37] fetch full history for the lint job so attgo can resolve changed files attgo's current-year analyzer resolves its changed-file set by shelling out to `git diff --name-status origin/master...HEAD`. Under actions/checkout@v4's default fetch-depth of 1 there is no origin/master ref and no origin/HEAD, so gitDefaultBranch() errors, the analyzer warns to stderr and falls back to checking every file in the repo instead of the ones the branch touches. The fallback is observable in the message it emits: the "new or modified files" wording comes from the fileStatusUnchanged branch, which is only reachable when git resolution failed. With only-new-issues filtering an all-files run, an arbitrary subset leaked through as failures on files this branch never touched. Fetching full history lets the three-dot diff resolve, so the analyzer checks the branch's own files and nothing else. --- .github/workflows/golangci-lint.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8a5793991..519633af8 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,6 +15,12 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + # attgo's current-year analyzer resolves the changed-file set with + # `git diff origin/master...HEAD`. The default shallow checkout has no + # origin/master ref, so the analyzer fails open and checks every file in + # the repo rather than the ones this branch touches. + fetch-depth: 0 - uses: actions/setup-go@v5 with: From 1eae874d7a080522255bfad6d373ff507455db77 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 13:14:55 +0200 Subject: [PATCH 20/37] fix the gloas.BeaconState JSON codec's bitvector, and Builder's dropped version execution_payload_availability is a Bitvector[SLOTS_PER_HISTORICAL_ROOT], which beacon-APIs types/primitive.yaml defines as a single hex string. The codec had it as []string, and both directions were wrong independently, for different reasons. Marshal ranged over the bytes and emitted one decimal string each. Unmarshal decoded straight into the []uint8 field, which gets encoding/json's []byte special case -- base64, not hex -- so a real node's own output was rejected outright. That is the reported failure: "illegal base64 data at input byte 16", reproduced byte-for-byte by the new test. Byte 16 is a fingerprint rather than a coincidence: "0xbffbffffffffffff" is 18 chars all of which happen to be in the base64 alphabet, so the decode clears four quanta and only then chokes on the 2-char remainder. The fix is the justification_bits form three fields earlier in the same file, and the sibling beaconstate_yaml.go was already correct for this very field, so the JSON codec was the lone outlier rather than a new pattern being invented. Key decisions: - The field-by-field audit the work item asked for found a second defect and it is fixed here rather than filed: Builder.unpack decoded version into its intermediate struct and never assigned it, so every builder read over JSON silently claimed version 0. Fixed because the item's own round-trip criterion is unmeetable while it stands -- a real state contains builders -- and because version is hashed, so a JSON-decoded state computed a different state root than the node's. That last point came from review and is a stronger reason than the one the change was originally made on. - Builder.MarshalYAML omitted version from its literal too, so the read fix alone left the type half-repaired: YAML could read the field but never wrote it. Also fixed. Version lives in three independently hand-written composite literals -- MarshalJSON, unpack, MarshalYAML -- and drift between hand-copied field lists is the actual mechanism behind this whole family. Each literal now has exactly one test guarding it, which is what the mutation run below demonstrates. - Nothing else was folded in. Four further findings are filed instead, three of them escalated by review beyond how they first looked: Builder.version is emitted as a JSON number where primitive.yaml's Uint8 is a string, and that is a hard decode failure rather than a cosmetic mismatch, since an untagged uint8 cannot accept a JSON string at all; BuilderPendingPayment and BuilderPendingWithdrawal carry no JSON tags at all and serialise with PascalCase Go field names; the bitvector marshal emits "" rather than "0x" for an empty value, violating the pattern this change's own test asserts; and the per-element fmt.Sprintf loops in this file cost ~79ms per mainnet-state marshal, ~1500x what this fix saves. The first two are latent consensus-spec-vector failures that will surface the moment those vectors exist. - No length or width check was added. That is deliberate: the policy is owned elsewhere and actively disputed, and justification_bits has none either. - The two audit instruments are deliberately different, because a round-trip is structurally blind to a field wrong the same way in both directions. Shapes are therefore checked against the spec rather than against our own output. Demonstrated, not asserted: making ptc_window hex in both directions passes the round-trip and fails only the shape test. The same run also refutes the work item's guess that ptc_window was the next bad field -- it is a Vector of ValidatorIndex, so decimal-per-entry is correct there, and the test now pins that against being "fixed" to match its neighbour. - The bug reached live validation because the pre-existing fixture leaves this field nil, and an empty array round-trips clean. Nil it again with the bug restored and the round-trip still passes, which is the mechanism exactly. Hence a fixture populating all 46 fields; it is also what caught the builder defect. - The type change repairs the YAML read path as a side effect worth knowing about. BeaconState.UnmarshalYAML deliberately routes through beaconStateJSON, so the JSON struct's field type governs YAML too: the correct hex string from the YAML marshaller met a []string and failed with "string was used where sequence is expected". gloas.BeaconState YAML round-tripping was broken outright, and the consensus-spec tests that would have caught it skip without vectors on disk. Files changed: - new: spec/gloas/beaconstate_test.go, spec/gloas/builder_test.go - modified: spec/gloas/beaconstate_json.go (field type, marshal, unmarshal), spec/gloas/builder.go (2 assignments), spec/gloas/jsonlength_test.go (BeaconState round-trip row + the amended note its widened scope needs) 5 files, 1 struct field retyped, 4 production lines changed. Verified: go build clean; gosilent test ./... 2390 green, up from 2383; -race clean on spec; go vet and gofmt clean; go generate ./spec/gloas leaves the generated siblings byte-identical; ./custom-gcl 0 issues repo-wide. Note the lint gate is ./custom-gcl, not golangci-lint, which exits 3 here. Live validation is deferred rather than skipped quietly: no Gloas endpoint is reachable, and http/ tests skip entirely without HTTP_ADDRESS, so the fix is proved by captured fixture instead. The audit was driven off the upstream spec at v1.7.0-alpha.12 and beacon-APIs master, both re-fetched during review rather than recalled. All 46 top-level fields are present, in spec order, with the correct shape class; execution_payload_availability was the only byte-slice field in spec/gloas mis-declared, on the mechanical criterion that a dynssz-size of SOMETHING/8 marks a packed bitvector. Coverage is mutation-proven, 6 mutations each failing exactly its own tests, and two of them exist only because a reviewer found them: reverting the unmarshal fails the JSON test, the YAML test and the table row; dropping the 0x prefix fails the JSON and shape tests but not the round-trip, since both directions still agree; ptc_window hex both ways fails only the shape test; and version dropped from each of the three literals fails a different single test in each case. Efficiency measured the fix at mainnet width rather than the test's minimal one: 65,405ns and 1,003 allocs become 13,959ns and 20, with the wire form 2.78x smaller. That ratio also explains the silence -- the old cost was one allocation per byte, so at the 8-byte minimal preset the tests use it sat inside the noise floor, and only 1024 bytes makes it visible. Blockers / notes for next iteration: - The include-a-real-node half of the acceptance criteria stays unticked. Nothing here has been seen by a Gloas node. - Whether Builder.version should be a quoted string on the wire cannot be settled without one. Prysm may well emit a bare number today, in which case conforming to the spec breaks against prysm; accepting both on read is the likely landing, and it is filed rather than guessed at. - The duplicated, mislabelled execution-address length check a few lines below the fix in builder.go is untouched. It is pre-existing and already owned by another work item, so fixing it here would poach that item's scope. - Reviewers independently reproduced two pre-existing hazards outside this diff: HashTreeRoot's zero-padding writes past len into a caller-supplied backing array, unreachable from here because hex.DecodeString always returns cap == len; and response bodies are still read unbounded before any codec runs, which is why the unvalidated bitvector width cannot allocate anything not already committed. --- spec/gloas/beaconstate_json.go | 14 +- spec/gloas/beaconstate_test.go | 266 +++++++++++++++++++++++++++++++++ spec/gloas/builder.go | 3 + spec/gloas/builder_test.go | 74 +++++++++ spec/gloas/jsonlength_test.go | 13 ++ 5 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 spec/gloas/beaconstate_test.go create mode 100644 spec/gloas/builder_test.go diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go index b7dde46ed..257d8815e 100644 --- a/spec/gloas/beaconstate_json.go +++ b/spec/gloas/beaconstate_json.go @@ -72,7 +72,7 @@ type beaconStateJSON struct { ProposerLookahead []string `json:"proposer_lookahead"` Builders []*Builder `json:"builders"` NextWithdrawalBuilderIndex string `json:"next_withdrawal_builder_index"` - ExecutionPayloadAvailability []string `json:"execution_payload_availability"` + ExecutionPayloadAvailability string `json:"execution_payload_availability"` BuilderPendingPayments []*BuilderPendingPayment `json:"builder_pending_payments"` BuilderPendingWithdrawals []*BuilderPendingWithdrawal `json:"builder_pending_withdrawals"` LatestExecutionPayloadBid *ExecutionPayloadBid `json:"latest_execution_payload_bid"` @@ -110,10 +110,6 @@ func (b *BeaconState) MarshalJSON() ([]byte, error) { for i := range b.ProposerLookahead { proposerLookahead[i] = fmt.Sprintf("%d", b.ProposerLookahead[i]) } - executionPayloadAvailability := make([]string, len(b.ExecutionPayloadAvailability)) - for i := range b.ExecutionPayloadAvailability { - executionPayloadAvailability[i] = fmt.Sprintf("%d", b.ExecutionPayloadAvailability[i]) - } ptcWindow := make([][]string, len(b.PTCWindow)) for i := range b.PTCWindow { ptcWindow[i] = make([]string, len(b.PTCWindow[i])) @@ -163,7 +159,7 @@ func (b *BeaconState) MarshalJSON() ([]byte, error) { ProposerLookahead: proposerLookahead, Builders: b.Builders, NextWithdrawalBuilderIndex: fmt.Sprintf("%d", b.NextWithdrawalBuilderIndex), - ExecutionPayloadAvailability: executionPayloadAvailability, + ExecutionPayloadAvailability: fmt.Sprintf("%#x", b.ExecutionPayloadAvailability), BuilderPendingPayments: b.BuilderPendingPayments, BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, @@ -408,7 +404,11 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { return errors.Wrap(err, "next_withdrawal_builder_index") } - if err := json.Unmarshal(raw["execution_payload_availability"], &b.ExecutionPayloadAvailability); err != nil { + // 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") } diff --git a/spec/gloas/beaconstate_test.go b/spec/gloas/beaconstate_test.go new file mode 100644 index 000000000..3fdaefb6a --- /dev/null +++ b/spec/gloas/beaconstate_test.go @@ -0,0 +1,266 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "regexp" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// populatedBeaconState returns a state with every one of BeaconState's 46 fields +// carrying a distinctive non-zero value, which is what makes a round-trip an audit +// rather than a formality: a field left at its zero value hides a codec that +// mishandles it, since an absent or empty value round-trips clean either way. +// +// Vectors carry two entries rather than their spec length. Nothing in the JSON +// codec checks a vector's width, so a spec-sized 8192 block_roots would inflate +// the fixture without testing anything more. +func populatedBeaconState() *gloas.BeaconState { + state := &gloas.BeaconState{} + + state.GenesisTime = 1 + state.GenesisValidatorsRoot = phase0.Root{0x01} + state.Slot = 2 + state.Fork = &phase0.Fork{ + PreviousVersion: phase0.Version{0x02}, + CurrentVersion: phase0.Version{0x03}, + Epoch: 3, + } + state.LatestBlockHeader = &phase0.BeaconBlockHeader{ + Slot: 4, + ProposerIndex: 5, + ParentRoot: phase0.Root{0x04}, + StateRoot: phase0.Root{0x05}, + BodyRoot: phase0.Root{0x06}, + } + state.BlockRoots = []phase0.Root{{0x07}, {0x08}} + state.StateRoots = []phase0.Root{{0x09}, {0x0a}} + state.HistoricalRoots = []phase0.Root{{0x0b}, {0x0c}} + state.ETH1Data = &phase0.ETH1Data{ + DepositRoot: phase0.Root{0x0d}, + DepositCount: 6, + BlockHash: make([]byte, phase0.Hash32Length), + } + state.ETH1DataVotes = []*phase0.ETH1Data{{ + DepositRoot: phase0.Root{0x0e}, + DepositCount: 7, + BlockHash: make([]byte, phase0.Hash32Length), + }} + state.ETH1DepositIndex = 8 + state.Validators = []*phase0.Validator{{ + PublicKey: phase0.BLSPubKey{0x0f}, + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 9, + ActivationEligibilityEpoch: 10, + ActivationEpoch: 11, + ExitEpoch: 12, + WithdrawableEpoch: 13, + }} + state.Balances = []phase0.Gwei{14, 15} + state.RANDAOMixes = []phase0.Root{{0x10}, {0x11}} + state.Slashings = []phase0.Gwei{16, 17} + state.PreviousEpochParticipation = []altair.ParticipationFlags{1, 2} + state.CurrentEpochParticipation = []altair.ParticipationFlags{3, 4} + state.JustificationBits = bitfield.Bitvector4{0x0b} + state.PreviousJustifiedCheckpoint = &phase0.Checkpoint{Epoch: 18, Root: phase0.Root{0x12}} + state.CurrentJustifiedCheckpoint = &phase0.Checkpoint{Epoch: 19, Root: phase0.Root{0x13}} + state.FinalizedCheckpoint = &phase0.Checkpoint{Epoch: 20, Root: phase0.Root{0x14}} + state.InactivityScores = []uint64{21, 22} + state.CurrentSyncCommittee = &altair.SyncCommittee{ + Pubkeys: []phase0.BLSPubKey{{0x15}}, + AggregatePubkey: phase0.BLSPubKey{0x16}, + } + state.NextSyncCommittee = &altair.SyncCommittee{ + Pubkeys: []phase0.BLSPubKey{{0x17}}, + AggregatePubkey: phase0.BLSPubKey{0x18}, + } + state.LatestBlockHash = phase0.Hash32{0x19} + state.NextWithdrawalIndex = 23 + state.NextWithdrawalValidatorIndex = 24 + state.HistoricalSummaries = []*capella.HistoricalSummary{{ + BlockSummaryRoot: phase0.Root{0x1a}, + StateSummaryRoot: phase0.Root{0x1b}, + }} + state.DepositRequestsStartIndex = 25 + state.DepositBalanceToConsume = 26 + state.ExitBalanceToConsume = 27 + state.EarliestExitEpoch = 28 + state.ConsolidationBalanceToConsume = 29 + state.EarliestConsolidationEpoch = 30 + state.PendingDeposits = []*electra.PendingDeposit{{ + Pubkey: phase0.BLSPubKey{0x1c}, + WithdrawalCredentials: make([]byte, 32), + Amount: 31, + Signature: phase0.BLSSignature{0x1d}, + Slot: 32, + }} + state.PendingPartialWithdrawals = []*electra.PendingPartialWithdrawal{{ + ValidatorIndex: 33, + Amount: 34, + WithdrawableEpoch: 35, + }} + state.PendingConsolidations = []*electra.PendingConsolidation{{ + SourceIndex: 36, + TargetIndex: 37, + }} + state.ProposerLookahead = []phase0.ValidatorIndex{38, 39} + state.Builders = []*gloas.Builder{{ + PublicKey: phase0.BLSPubKey{0x1e}, + Version: 1, + ExecutionAddress: bellatrix.ExecutionAddress{0x1f}, + Balance: 40, + DepositEpoch: 41, + WithdrawableEpoch: 42, + }} + state.NextWithdrawalBuilderIndex = 43 + state.ExecutionPayloadAvailability = []uint8{0xbf, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + state.BuilderPendingPayments = []*gloas.BuilderPendingPayment{{ + Weight: 44, + Withdrawal: &gloas.BuilderPendingWithdrawal{ + FeeRecipient: bellatrix.ExecutionAddress{0x20}, + Amount: 45, + BuilderIndex: 46, + }, + ProposerIndex: 47, + }} + state.BuilderPendingWithdrawals = []*gloas.BuilderPendingWithdrawal{{ + FeeRecipient: bellatrix.ExecutionAddress{0x21}, + Amount: 48, + BuilderIndex: 49, + }} + state.LatestExecutionPayloadBid = &gloas.ExecutionPayloadBid{ + ParentBlockHash: phase0.Hash32{0x22}, + ParentBlockRoot: phase0.Root{0x23}, + BlockHash: phase0.Hash32{0x24}, + FeeRecipient: bellatrix.ExecutionAddress{0x25}, + GasLimit: 50, + BuilderIndex: 51, + Slot: 52, + Value: 53, + PrevRandao: phase0.Root{0x26}, + ExecutionPayment: 57, + BlobKZGCommitments: []deneb.KZGCommitment{{0x28}}, + ExecutionRequestsRoot: phase0.Root{0x29}, + } + state.PayloadExpectedWithdrawals = []*capella.Withdrawal{{ + Index: 54, + ValidatorIndex: 55, + Address: bellatrix.ExecutionAddress{0x27}, + Amount: 56, + }} + state.PTCWindow = [][]phase0.ValidatorIndex{{57, 58}, {59, 60}} + + return state +} + +// rawField returns the verbatim JSON value the given key carries in input, +// which is what lets a test assert a field's wire shape and not merely its value. +func rawField(t *testing.T, input, key string) string { + t.Helper() + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(input), &doc)) + require.Contains(t, doc, key) + + return string(doc[key]) +} + +// TestBeaconStateExecutionPayloadAvailabilityJSON verifies that +// execution_payload_availability is carried as a single 0x-prefixed hex string in +// both directions. beacon-APIs types/primitive.yaml defines Bitvector as +// {type: string, format: hex, pattern "^0x[a-fA-F0-9]{2,}$"}, and the consensus +// spec types this field Bitvector[SLOTS_PER_HISTORICAL_ROOT]. +// +// Pinning the marshalled field against the literal a node actually sent is what +// makes decoding our own output equivalent to decoding the node's. Without that +// anchor a codec wrong the same way in both directions would satisfy the decode +// half of this test. +func TestBeaconStateExecutionPayloadAvailabilityJSON(t *testing.T) { + // The value observed from a live Gloas node, whose minimal preset makes + // SLOTS_PER_HISTORICAL_ROOT 64 and so the bitvector 8 bytes wide. + const availability = `"0xbffbffffffffffff"` + + state := populatedBeaconState() + input := mustMarshal(t, state) + require.Equal(t, availability, rawField(t, input, "execution_payload_availability")) + + var decoded gloas.BeaconState + require.NoError(t, json.Unmarshal([]byte(input), &decoded)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) +} + +// TestBeaconStateExecutionPayloadAvailabilityYAML verifies that the bitvector +// survives a YAML round trip. UnmarshalYAML deliberately routes through +// beaconStateJSON rather than maintaining a second unmarshaler, so the JSON struct's +// field types govern the YAML read path too. That coupling is what made this defect +// break YAML as well: beaconstate_yaml.go already emitted the field as one hex +// string, which then met a []string and failed with "string was used where sequence +// is expected". +// +// Whole states are not compared because other fields have their own YAML gaps, +// tracked separately. +func TestBeaconStateExecutionPayloadAvailabilityYAML(t *testing.T) { + state := populatedBeaconState() + + data, err := state.MarshalYAML() + require.NoError(t, err) + + var decoded gloas.BeaconState + require.NoError(t, decoded.UnmarshalYAML(data)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) +} + +// TestBeaconStateJSONWireShapes pins the wire shape of the BeaconState fields +// whose SSZ type does not map onto JSON in the way their Go type suggests. These +// are the fields a round-trip cannot vouch for: a codec that is wrong the same +// way in both directions round-trips perfectly and still rejects a real node's +// JSON, so the shapes here are taken from the spec rather than from our output. +func TestBeaconStateJSONWireShapes(t *testing.T) { + doc := mustMarshal(t, populatedBeaconState()) + + // beacon-APIs types/primitive.yaml defines Bitvector as {type: string, format: + // hex, pattern "^0x[a-fA-F0-9]{2,}$"}. Both of BeaconState's bitvectors are + // therefore a single string: justification_bits is + // Bitvector[JUSTIFICATION_BITS_LENGTH] and execution_payload_availability is + // Bitvector[SLOTS_PER_HISTORICAL_ROOT]. Their Go types differ — + // bitfield.Bitvector4 against a plain []uint8 — but their wire shape does not. + bitvector := regexp.MustCompile(`^0x[a-fA-F0-9]{2,}$`) + for _, field := range []string{"justification_bits", "execution_payload_availability"} { + t.Run(field, func(t *testing.T) { + var value string + require.NoError(t, json.Unmarshal([]byte(rawField(t, doc, field)), &value)) + require.Regexp(t, bitvector, value) + }) + } + + // ptc_window is Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + + // MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] — nested arrays of decimal strings, + // uint64 entries rather than packed bits. The decimal-per-entry shape that was + // wrong for execution_payload_availability is the correct shape here, so this + // pins it against being "fixed" to match its neighbour. + var ptcWindow [][]string + require.NoError(t, json.Unmarshal([]byte(rawField(t, doc, "ptc_window")), &ptcWindow)) + require.Equal(t, [][]string{{"57", "58"}, {"59", "60"}}, ptcWindow) +} diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go index f2b9b7e0d..8c7c57855 100644 --- a/spec/gloas/builder.go +++ b/spec/gloas/builder.go @@ -95,6 +95,8 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { copy(v.PublicKey[:], publicKey) + v.Version = builderJSON.Version + if builderJSON.ExecutionAddress == "" { return errors.New("execution address missing") } @@ -154,6 +156,7 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { func (v *Builder) MarshalYAML() ([]byte, error) { yamlBytes, err := yaml.MarshalWithOptions(&builderYAML{ PublicKey: fmt.Sprintf("%#x", v.PublicKey), + Version: v.Version, ExecutionAddress: v.ExecutionAddress.String(), Balance: fmt.Sprintf("%d", v.Balance), DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), diff --git a/spec/gloas/builder_test.go b/spec/gloas/builder_test.go new file mode 100644 index 000000000..be6b10459 --- /dev/null +++ b/spec/gloas/builder_test.go @@ -0,0 +1,74 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// testBuilder returns a builder whose every field carries a distinctive value, so +// a field dropped by one of the four hand-written codec literals shows up as a +// difference rather than as a coincidentally-matching zero. +func testBuilder() *gloas.Builder { + return &gloas.Builder{ + PublicKey: phase0.BLSPubKey{0x01}, + Version: 1, + ExecutionAddress: bellatrix.ExecutionAddress{0x02}, + Balance: 3, + DepositEpoch: 4, + WithdrawableEpoch: 5, + } +} + +// TestBuilderVersionUnmarshal verifies that a builder's version survives a JSON +// decode. beacon-APIs types/gloas/builder.yaml lists version among Builder's +// required properties, and the SSZ codec carries it, so a JSON decode that drops +// it hands the caller a builder that silently claims version 0. +// +// The field is decoded into the intermediate JSON struct but was never copied +// across to the target, so every builder read over JSON lost it. +func TestBuilderVersionUnmarshal(t *testing.T) { + builder := testBuilder() + + var decoded gloas.Builder + require.NoError(t, json.Unmarshal([]byte(mustMarshal(t, builder)), &decoded)) + require.Equal(t, builder, &decoded) +} + +// TestBuilderVersionYAML verifies that a builder's version survives a YAML +// round-trip. The read direction is shared — UnmarshalYAML routes through the +// same unpack helper UnmarshalJSON uses — but MarshalJSON and MarshalYAML are two +// independent composite literals, so only the JSON one was ever populated. The +// consensus-spec vectors would catch it, but they are absent, so this is the only +// guard. +// +// The unquoted form asserted below is the current shape, not an endorsement of it: +// whether a spec Uint8 should be a quoted string here is a separate open question. +func TestBuilderVersionYAML(t *testing.T) { + builder := testBuilder() + + data, err := builder.MarshalYAML() + require.NoError(t, err) + require.Contains(t, string(data), "version: 1") + + var decoded gloas.Builder + require.NoError(t, decoded.UnmarshalYAML(data)) + require.Equal(t, builder, &decoded) +} diff --git a/spec/gloas/jsonlength_test.go b/spec/gloas/jsonlength_test.go index 0a8ae000c..b4c5cc1fa 100644 --- a/spec/gloas/jsonlength_test.go +++ b/spec/gloas/jsonlength_test.go @@ -280,6 +280,11 @@ func TestJSONFixedLengthGuards(t *testing.T) { // 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 @@ -332,6 +337,14 @@ func TestJSONFixedLengthRoundTrip(t *testing.T) { 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 { From 5442e8d6da86bdcdfeb77b8b96271e6e33bb8d9f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 14:28:17 +0200 Subject: [PATCH 21/37] encode the proposal SSZ body at the node's preset, not mainnet's submitProposalSSZ marshalled with each fork container's generated codec, which has the mainnet preset compiled in. A block body holds a preset-sized sync committee bitvector -- 512 bits at mainnet, 32 at minimal -- and SSZ addresses the variable-size fields after it by offsets, so at any other preset the whole body shifts by 60 bytes and the node cannot read it. It failed silently, which is the part that made it expensive to diagnose. The generated codec does not reject a short bitvector; it pads it out to the mainnet length and returns no error. Measured: a block whose SyncCommitteeBits is 4 bytes and one whose bits are 64 bytes marshal to byte-identical output, 1132 bytes for electra and 924 for gloas, err=nil both times. Nothing local can observe the mismatch, so the first sign of it is a server-side rejection far from the cause. The fix routes every fork arm through marshalRequestBody, which already picks JSON vs SSZ and encodes SSZ with the request-scoped dynamic codec. That is the helper the two newer submitters use, so submitproposal.go was the last one still on the generated codec. Key decisions: - The two duplicated eight-arm version switches collapse into one. This is cause, not tidiness: the duplication is what let the SSZ arm drift from the JSON one, and the Gloas arm carried a five-line comment apologising for a defect it could not fix locally. The JSON/SSZ choice now exists once repo-wide. - The switch is inlined in submitProposalData rather than extracted, matching submitexecutionpayloadbid and submitexecutionpayloadenvelope, and the local is named container rather than block -- three of the eight arms are SignedBlockContents wrappers, not bare blocks, and this is a distinction the rest of the codebase names carefully. - The default arm stays although it is unreachable: AssertPresent runs first and rejects every version the switch does not name, which is why the new UnknownVersion test asserts "unsupported version" and not the switch's own error. It is kept so the two switches cannot drift apart silently, and now says so. - Nothing was folded in. Three findings are filed instead, two of them about the shared codec helper rather than this endpoint: it rebuilds its codec per call and throws away the type cache, measured at ~178us and 2381 allocs against ~596ns and 2 for a reused instance, and >99% of that is construct-and-discard that happens whether or not the preset actually diverges; node-supplied spec values now size an outbound body with no bound past uint32, where SYNC_COMMITTEE_SIZE=4294967295 yields a 536MB body with err=nil; and AssertPresent accepts a blinded Bellatrix-through-Fulu proposal while the switch reaches for the nil non-blinded arm, publishing a zero-valued block. The last is pre-existing and byte-for-byte unchanged here. - No bound was added on the spec values, deliberately. The helper is shared by four call sites and the width-check policy is owned by an existing family of items and actively disputed, so fixing it here would poach that scope. What this change alters is breadth rather than kind: three ePBS routes already drove encoding off node-supplied spec values, and this puts the universal publish path on the same footing. Files changed: - modified: http/submitproposal.go (two switches to one, 89 lines deleted), http/submitproposal_internal_test.go (2 new tests, the existing one rewritten to drive submitProposalData now that the two encoders it called are gone) 2 files, 156 insertions, 108 deletions. 4 production lines do the work. Verified live, before and after, against a minimal-preset node with gloas active, reproduced twice as the devnet's canonical endpoint moved. Before: 924 bytes, HTTP 400, "could not decode request body into gloas consensus block: Block: Body: invalid ssz encoding. first variable element offset indexes into fixed value data" -- the node's own words for the 60-byte shift. After: 864 bytes, HTTP 500, "could not get block's prestate: could not reconstruct parent state", so the body parses and is rejected on consensus grounds because the fixture has a zero parent root. Getting past decode is the signal. Also verified: go build, go vet and gofmt clean; ./custom-gcl 0 issues repo-wide (note the gate is ./custom-gcl, not golangci-lint, which exits 3 here); -race clean. The full suite's failing set is byte-identical to a detached worktree at the previous commit, so none of it is attributable here; a later run found two further pre-existing failures and some of them are non-deterministic, the devnet having degraded to UNHEALTHY mid-session. Coverage is mutation-proven. Cross-wiring the electra arm to fulu fails the parity test on electra alone, with the byte diff landing exactly on the slot field, 0x258 against 0x2bc; dropping the AssertPresent guard fails MissingArm, MarkedBlinded and UnknownVersion, the last by emitting the switch's error instead of the presence check's, which is what pins which guard runs first. The preset test's own RED was the node-side error reproduced in-process, "first offset 396 does not match expected 336". The parity test earns its place separately from the fix: it pins that the dynamic codec leaves the mainnet wire format alone. Global dynssz output is byte-identical to the generated codec for all eight arms, so the default path is a zero-wire-change refactor, and each arm is given a slot unique to its fork so a mis-wire encodes a different valid block rather than a nil one -- two empty containers could otherwise encode identically and let it pass. Blockers / notes for next iteration: - The fix is inert unless the caller sets WithCustomSpecSupport(true). Without a spec to work from the client has nothing to encode against, so the honest claim is that a custom-spec client can now publish, not that any non-mainnet client can. The impact line as originally written overstated this. - A nil arm marshals to a zero-valued block with err=nil under both codecs rather than panicking, because the generated code carries nil-receiver guards. The bytes are not all zero, since the SSZ offsets are not. - Not verified: that a genuinely valid block is accepted. That needs a real signed block for the current slot, which no synthetic fixture provides. --- http/submitproposal.go | 108 +++---------------- http/submitproposal_internal_test.go | 156 ++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 108 deletions(-) diff --git a/http/submitproposal.go b/http/submitproposal.go index 40e624710..1b8b3808c 100644 --- a/http/submitproposal.go +++ b/http/submitproposal.go @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "strings" @@ -71,116 +70,37 @@ func (s *Service) submitProposalData(ctx context.Context, ContentType, error, ) { - var ( - body []byte - contentType ContentType - err error - ) - - if s.enforceJSON { - contentType = ContentTypeJSON - body, err = s.submitProposalJSON(ctx, proposal) - } else { - contentType = ContentTypeSSZ - body, err = s.submitProposalSSZ(ctx, proposal) - } - - if err != nil { + if err := proposal.AssertPresent(); 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 - } + var container any switch proposal.Version { case spec.DataVersionPhase0: - specJSON, err = json.Marshal(proposal.Phase0) + container = proposal.Phase0 case spec.DataVersionAltair: - specJSON, err = json.Marshal(proposal.Altair) + container = proposal.Altair case spec.DataVersionBellatrix: - specJSON, err = json.Marshal(proposal.Bellatrix) + container = proposal.Bellatrix case spec.DataVersionCapella: - specJSON, err = json.Marshal(proposal.Capella) + container = proposal.Capella case spec.DataVersionDeneb: - specJSON, err = json.Marshal(proposal.Deneb) + container = proposal.Deneb case spec.DataVersionElectra: - specJSON, err = json.Marshal(proposal.Electra) + container = proposal.Electra case spec.DataVersionFulu: - specJSON, err = json.Marshal(proposal.Fulu) + container = proposal.Fulu case spec.DataVersionGloas: // A plain signed block, unlike the contents wrappers Deneb through Fulu // publish: post-Gloas the blobs travel in the execution payload // envelope, which is published through its own endpoint. - specJSON, err = json.Marshal(proposal.Gloas) + container = proposal.Gloas default: - err = errors.New("unknown proposal version") - } - - if err != nil { - return nil, errors.Join(errors.New("failed to marshal JSON"), 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 - } - - switch proposal.Version { - case spec.DataVersionPhase0: - specSSZ, err = proposal.Phase0.MarshalSSZ() - case spec.DataVersionAltair: - specSSZ, err = proposal.Altair.MarshalSSZ() - case spec.DataVersionBellatrix: - specSSZ, err = proposal.Bellatrix.MarshalSSZ() - case spec.DataVersionCapella: - specSSZ, err = proposal.Capella.MarshalSSZ() - case spec.DataVersionDeneb: - specSSZ, err = proposal.Deneb.MarshalSSZ() - case spec.DataVersionElectra: - specSSZ, err = proposal.Electra.MarshalSSZ() - case spec.DataVersionFulu: - specSSZ, err = proposal.Fulu.MarshalSSZ() - case spec.DataVersionGloas: - // Marshaled with the generated codec, as every arm above it is. Note - // that makes this path mainnet-preset only, since a block body holds a - // preset-sized sync committee bitvector: at another preset the codec - // pads it to the mainnet length and the node rejects the result. That - // affects every fork from altair onwards, not just this arm, so it is - // tracked as its own defect rather than worked around here. - specSSZ, err = proposal.Gloas.MarshalSSZ() - 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_internal_test.go b/http/submitproposal_internal_test.go index a3c718606..48fb0ce60 100644 --- a/http/submitproposal_internal_test.go +++ b/http/submitproposal_internal_test.go @@ -18,8 +18,17 @@ import ( "encoding/json" "testing" + bitfield "github.com/OffchainLabs/go-bitfield" "github.com/attestantio/go-eth2-client/api" + apiv1deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" + apiv1electra "github.com/attestantio/go-eth2-client/api/v1/electra" + apiv1fulu "github.com/attestantio/go-eth2-client/api/v1/fulu" "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" @@ -42,16 +51,126 @@ func gloasSignedProposal() *api.VersionedSignedProposal { } } -// TestSubmitProposalGloasMarshaling covers the gloas arm of both request-body -// encoders. Without an arm each falls through to "unknown proposal version", so -// the block never leaves the process and no server-side test can tell why. +// 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, err := s.submitProposalJSON(ctx, gloasSignedProposal()) + 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. @@ -63,8 +182,9 @@ func TestSubmitProposalGloasMarshaling(t *testing.T) { }) t.Run("SSZ", func(t *testing.T) { - body, err := s.submitProposalSSZ(ctx, gloasSignedProposal()) + 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 @@ -75,17 +195,19 @@ func TestSubmitProposalGloasMarshaling(t *testing.T) { }) // A version claiming gloas with no gloas arm populated must be refused - // before anything is marshaled: the encoders switch on Version, so an - // unchecked nil arm marshals as JSON null or panics on the SSZ path. + // 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.submitProposalJSON(ctx, proposal) - require.ErrorContains(t, err, "gloas proposal not present") - - _, err = s.submitProposalSSZ(ctx, proposal) + _, _, err := s.submitProposalData(ctx, proposal) require.ErrorContains(t, err, "gloas proposal not present") }) @@ -99,11 +221,17 @@ func TestSubmitProposalGloasMarshaling(t *testing.T) { require.ErrorContains(t, proposal.AssertPresent(), "gloas proposals are never blinded") - _, err := s.submitProposalJSON(ctx, proposal) + _, _, err := s.submitProposalData(ctx, proposal) require.ErrorContains(t, err, "gloas proposals are never blinded") + }) - _, err = s.submitProposalSSZ(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 From f9c44b2aeb18dedf4965039695ed17585d7f931d Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 17:04:13 +0200 Subject: [PATCH 22/37] give package http's internal tests one service constructor, so they send the token Two internal test helpers built their service with a bare New() and no Authorization header, so they failed against any node requiring auth. New() pings via CheckConnectionState and returns client.ErrNotActive on failure, so the whole diagnosis a reader gets is "client is not active", which names neither auth nor a header. The cause is a package boundary rather than carelessness. newTestService, which centralises address, timeout and token, lives in main_test.go in package http_test; the internal tests are package http and cannot see it. Every one of them therefore re-derived New()'s parameters by hand, and two of the four got it wrong. The events_internal_test.go pair had the correct if/else all along, which is what makes this a consistency failure and not a knowledge one. The fix is one constructor for package http, mirroring the one the external package already has, with all four call sites moved onto it. Key decisions: - The helper returns *Service, not (client.Service, error). Being inside the package it can, and three call sites were each asserting service.(*Service) afterwards -- one of them checking an assertion that cannot fail, since New only ever returns that type. Swallowing the error matches the two helpers that already existed here. - It is named newTestService, identical to its package http_test counterpart. Same directory, different packages, different signatures. The duplication is forced by Go's package rules, and the shared name is the strongest available signal that one is the other's counterpart; timeout already sits in both files under one name. - fmt.Sprintf("Bearer %s", token) is kept over concatenation, and the relocated timeout var is kept over inlining. Both were raised as simplifications and both are declined for the same reason: this file's value is being a faithful mirror, and the line doing the security-relevant work is the last place to introduce a gratuitous difference. timeout is also pre-existing code that merely moved. - The token test is a table with one row per constructor, deliberately redundant while all three route through the helper. Reviewed as over-engineering and defended on evidence: re-hand-rolling customSpecService fails its own row and leaves the other two green, so the rows catch a single call site drifting back off the helper -- the exact regression this commit exists to prevent, and one that already got past human review once. The comment says so, so it is not deleted later for the same reason it was questioned. - The two guards are separate tests because they cover different arms and a mutation each proves it: dropping the token block fails the table and not the omit test; making the header unconditional fails the omit test and not the table. - The four audited sites and the query that found them are named in the helper's doc comment, not only here, because a future reader asking whether the sweep was complete is reading the code. - Nothing else was folded in. Three findings are filed instead: a live node sends ptc_window as a JSON object where the gloas BeaconState codec expects an array, contradicting an earlier fixture-derived conclusion about that field; TestBeaconBlockRootTimeout is flaky, failing 3 of 5 runs at the previous commit because it asserts a 1ms timeout beats a localhost round trip; and beaconstate_test.go still re-hand-rolls the token branch that the external newTestService already provides, correct today but the same shape of duplication. Files changed: - new: http/main_internal_test.go (helper, relocated timeout, 2 tests) - modified: http/epbsguardwiring_internal_test.go, http/epbsproposal_internal_test.go, http/events_internal_test.go (call sites; 46 lines and 3 imports deleted) 4 files, 166 insertions, 46 deletions. The helper is the only new logic. Verified before and after against a genuinely authenticated endpoint, which is the point: these tests already passed against an unauthenticated node, so that configuration proves nothing. A reverse proxy requiring a bearer token in front of the devnet supplied one. Before, from a detached worktree at the previous commit, all three named tests failed with "client is not active"; after, all three pass. The proxy logged three 401s on /eth/v1/node/syncing with an empty Authorization header during the before run and none during the after run, which is the mechanism end to end. A reviewer reproduced this independently with its own proxy, port and token. TestSubmitProposalDataAtTheNodesPreset was fixed too, unnamed in the report but routed through the same customSpecService. Also verified: go build, go vet, gofmt clean on all four files (24 other files carry pre-existing gofmt noise, identical in a baseline worktree); ./custom-gcl 0 issues repo-wide, and note the gate is ./custom-gcl rather than golangci-lint, which exits 3 here; -race clean on the affected tests; gosilent test ./... 2390 green. Both configurations pass, authenticated and not: with no HTTP_BEARER_TOKEN the table synthesises one, and the node ignores it. The full-suite failing set was diffed against a detached worktree at the previous commit on the same endpoint. It removes exactly the three named tests plus TestSubmitProposalDataAtTheNodesPreset. Two entries differ for reasons that are not this change: TestBeaconBlockRootTimeout and TestAggregateAttestation/NotFound are both non-deterministic, the former demonstrated at 3 failures in 5 baseline runs. Blockers / notes for next iteration: - The devnet went down late in the session, so the last edits, which are comments only, were gated on build, vet, gofmt, lint and the full no-node suite rather than on a live re-run. The table test has not executed since; the omit test has, since it needs no node. - That omit test is the only one here that runs without a node. It works by pointing at a closed port with WithAllowDelayedStart, and it runs at all only because TestMain merely requires HTTP_ADDRESS to be non-empty. - The anti-drift guard does run in CI: http-tests.yml runs on every pull request with HTTP_ADDRESS from a repository variable and HTTP_BEARER_TOKEN from a secret. It does not run in the default test job, where TestMain returns before m.Run() and this directory contributes nothing. - A caller passing its own WithExtraHeaders would silently reinstate this bug, since that option replaces the header map rather than merging into it. Nothing does today and no guard was added; it is documented on the helper. --- http/epbsguardwiring_internal_test.go | 13 +-- http/epbsproposal_internal_test.go | 11 +- http/events_internal_test.go | 26 +---- http/main_internal_test.go | 162 ++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 http/main_internal_test.go diff --git a/http/epbsguardwiring_internal_test.go b/http/epbsguardwiring_internal_test.go index 18093d3b0..a9d75271d 100644 --- a/http/epbsguardwiring_internal_test.go +++ b/http/epbsguardwiring_internal_test.go @@ -31,7 +31,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "testing" client "github.com/attestantio/go-eth2-client" @@ -45,21 +44,13 @@ import ( func disagreeingService(ctx context.Context, t *testing.T, handler http.HandlerFunc) *Service { t.Helper() - address := os.Getenv("HTTP_ADDRESS") - if address == "" { - t.Skip("HTTP_ADDRESS not set") - } - // Built against the real node so that the spec fetch, the sync assertion and // the SSZ codec are all genuine; only the endpoint under test is faked. - service, err := New(ctx, WithAddress(address), WithCustomSpecSupport(true), WithEnforceJSON(true)) - require.NoError(t, err) - - s := service.(*Service) + s := newTestService(ctx, t, true, WithEnforceJSON(true)) // Warm the spec cache while the real address is still in place, since the // fake server does not serve it. - _, err = s.Spec(ctx, &api.SpecOpts{}) + _, err := s.Spec(ctx, &api.SpecOpts{}) require.NoError(t, err) server := httptest.NewServer(handler) diff --git a/http/epbsproposal_internal_test.go b/http/epbsproposal_internal_test.go index c5dabc772..5fad33817 100644 --- a/http/epbsproposal_internal_test.go +++ b/http/epbsproposal_internal_test.go @@ -19,7 +19,6 @@ import ( "fmt" "math/big" "net/http" - "os" "strings" "testing" @@ -557,13 +556,5 @@ func TestAssertEPBSProposalMatchesRequest(t *testing.T) { func customSpecService(ctx context.Context, t *testing.T) *Service { t.Helper() - address := os.Getenv("HTTP_ADDRESS") - if address == "" { - t.Skip("HTTP_ADDRESS not set") - } - - service, err := New(ctx, WithAddress(address), WithCustomSpecSupport(true)) - require.NoError(t, err) - - return service.(*Service) + return newTestService(ctx, t, true) } diff --git a/http/events_internal_test.go b/http/events_internal_test.go index ccdcf3003..c9c720c13 100644 --- a/http/events_internal_test.go +++ b/http/events_internal_test.go @@ -17,12 +17,8 @@ import ( "bytes" "context" "encoding/json" - "fmt" - "os" "testing" - "time" - client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/gloas" @@ -31,9 +27,6 @@ import ( "github.com/stretchr/testify/require" ) -// timeout for tests. -var timeout = 60 * time.Second - func TestEventHandler(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -277,24 +270,7 @@ func TestEventHandler(t *testing.T) { // 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) + h := newTestService(ctx, t, false) for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/http/main_internal_test.go b/http/main_internal_test.go new file mode 100644 index 000000000..969b3903c --- /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, 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") +} From 4dea4d2c2962556aa548c484254d54b6d770b24e Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 17:46:39 +0200 Subject: [PATCH 23/37] fork-gate the ePBS http tests, and run the suite against a second endpoint http-tests was permanently red: 19 FAIL lines against a node on fulu, from tests written against a Gloas devnet with no way to leave themselves out. A permanently red check gates nothing, because it cannot tell 19 known failures from 19 known plus a regression without someone diffing logs by hand. The 19 are FAIL lines, not tests. Go reports a parent and each subtest separately, so they are 10 top-level tests, and they do not share a precondition: most need the chain to be on Gloas, one needs only that the node implements the endpoints, and three were never a fork problem at all. Each fork-dependent test now asks the node at runtime and skips, and CI points the same suite at two endpoints. Key decisions: - Buckets were assigned from the error text, never the test name, and the CI log overrode the summary they were filed with. TestPTCDuties, TestPayloadAttestationPool and TestSubmitPayloadAttestationMessages each failed in one subtest only -- their NilOpts/NoIndices/NoMessages siblings passed, being client-side refusals that reach no network. Gating those parents would have buried five working subtests, which is the failure this change exists to prevent, arriving by the front door. Only TestEPBSProposal, whose four subtests all failed, is gated whole. - Two levels, not one. KnowsGloas reads GLOAS_FORK_EPOCH from the spec and gates the pre-Gloas-behaviour test; OnGloas reads the head block's fork and gates the rest. PreGloasSlotIsNotSentinel asks for an envelope at slot 1 and asserts a 400, so its only precondition is that an endpoint exists to refuse: keyed on the head's fork it would stay dark on a mainnet-lineage node forever, when it should start running there the day that node's client learns Gloas, years before activation. - PastSlotIsSentinel is gated although it passed. It passed for the wrong reason: a node without the endpoint 404s, and that maps to the very sentinel it asserts. - Presence, not arithmetic, for KnowsGloas: a client that knows the fork publishes a far-future epoch, and that counts as knowing. OnGloas reads the head's own version rather than comparing epochs -- one call, it is what the gated tests depend on since they all operate on head, and it cannot disagree with reality when a node's config and its chain diverge. - HeadVersion is a third helper so that OnGloas and the skip message are answered by one piece of code rather than two that can disagree. It reports "unknown" when the node cannot be asked, which keeps "head is unknown" readable; a mutation to "" prints "head is )". - HTTP_REQUIRE_GLOAS turns skip into fail, read in TestMain. gate takes an interface rather than *testing.T so that choice is testable at all: the real Skip and Fatalf end the calling goroutine, so a test cannot observe what was done to it. - The second job is advisory but cannot be quietly green. A missing address is fatal rather than skipped, because without one TestMain returns before running anything and the job would pass having executed no tests. - Neither job filters by name. Selection in YAML drifts against test names, and the first new ePBS test whose name misses the pattern lands in the gating job silently. Files changed: - new: testclients/forkutil.go (3 helpers), testclients/forkutil_test.go, http/gloasgate_test.go (2 gates, the inversion, and their guards) - modified: .github/workflows/http-tests.yml (second job), http/main_test.go (one knob), and 7 test files carrying 8 gate call sites 12 files, 561 insertions, no deletions. 4 helper bodies do the work. Verified live against a minimal-preset devnet with gloas active. All 8 gated tests run there with zero skips, which is the property that matters most: the gates open on a Gloas node. The inversion is proven end to end rather than by inspection. Same endpoint, two runs differing only in the variable, every gated test flipping SKIP to FAIL and each message naming its own level. The bucket-B assignment is proven against a node that knows Gloas but is not on it, since that is the only shape telling the two levels apart -- on any real node both predicates agree, which is why getting this wrong is invisible. A proxy supplied it. With the correct gate PreGloasSlotIsNotSentinel runs; rewired to OnGloas it skips, and both runs report green, so nothing but the SKIP line distinguishes them. A second proxy, on gloas but with the fork stripped from its config, showed the two levels are independent: PastSlotIsSentinel passes while its neighbour skips. Also verified: go build, go vet, gofmt clean; ./custom-gcl 0 issues repo-wide, and note the gate is ./custom-gcl rather than golangci-lint, which exits 3 here; -race clean with no data races, and no test in the package calls t.Parallel, so the one package-level variable is not shared under a race. The full suite's failing set was diffed against a detached worktree at the previous commit on the same endpoint: nothing fails that did not fail before, and no test gained a SKIP. 2771 tests against 2761, the 10 new ones being these. Coverage is mutation-proven, 6 mutations each failing its own tests: KnowsGloas pinned true fails only the absent row, pinned false only the present row and the live test; HeadVersion's fallback emptied fails the degradation test and the message test; OnGloas pointed at the wrong fork fails the live test; gate reduced to a no-op fails both arms of the inversion. One test earned its keep during the writing. The absent-spec row passed on the first run for the wrong reason -- Spec was returning "client is not active", so the predicate answered false from its error branch and asserted nothing about the key. A predicate returning false both for "key missing" and "could not ask" cannot be tested by its false case alone, so the present row is what makes the absent row mean anything. Finding out cost two iterations discovering that a service needs both /eth/v1/node/syncing and /eth/v1/node/version to come up active. Blockers / notes for next iteration: - http-tests-gloas needs two repository settings before it does anything: HTTP_ADDRESS_GLOAS, without which it now fails loudly by design, and optionally the HTTP_BEARER_TOKEN_GLOAS secret. Until the variable exists the job is red rather than misleading. - Not verified: that http-tests is green against the regression endpoint. That needs its address and token, which are repository settings not available here. The auth defect behind the three non-fork failures has landed, so the expectation is green, but it is an expectation. - OnGloas reads the head block, so the service must be able to decode one. At a non-mainnet preset that means custom spec support, without which the head reads as "unknown" and the gates close for a reason that is not the node's. The suite's shared service already has it. Documented on the helper. - The validation devnet will still show unrelated failures, block production there being v4-only. That is why the job is advisory. --- .github/workflows/http-tests.yml | 51 +++++ http/epbsproposal_test.go | 4 + http/executionpayloadenvelope_test.go | 13 ++ http/gloasgate_test.go | 186 ++++++++++++++++ http/main_test.go | 4 + http/payloadattestationpool_test.go | 4 + http/ptcduties_test.go | 6 + http/signedbeaconblock_test.go | 2 + http/submitpayloadattestationmessages_test.go | 2 + http/submitproposal_test.go | 2 + testclients/forkutil.go | 87 ++++++++ testclients/forkutil_test.go | 200 ++++++++++++++++++ 12 files changed, 561 insertions(+) create mode 100644 http/gloasgate_test.go create mode 100644 testclients/forkutil.go create mode 100644 testclients/forkutil_test.go diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index 26a2dc57b..ac98e8f10 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -27,9 +27,60 @@ jobs: exit 1 fi + # No -run selection: the whole suite runs, and the fork-dependent tests leave + # themselves out by asking this node what it is. Selecting by name here would + # put the choice in YAML, where it drifts against test names and the first new + # ePBS test whose name misses the pattern lands in this gating job silently. - name: Run HTTP tests env: HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS }} HTTP_TEST_CONCURRENCY: ${{ vars.HTTP_TEST_CONCURRENCY }} HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN }} run: go test -v -timeout=10m ./http/... + + # The same suite against a Gloas devnet, which is the only place the ePBS surface + # can be exercised at all: the endpoint above is on an earlier fork, and on a + # different preset, so between them they cover the shipped surface and the new one. + # + # Advisory for now. That devnet is interim and frequently respun, so a failure here + # must not block a pull request — but it must not be ignorable either, which is what + # the two settings below are for. + http-tests-gloas: + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.2' + + # Deliberately fatal rather than skipped. With no address the suite's TestMain + # returns before running anything and the job would report success having + # executed no tests at all — the silent-green outcome this job exists to avoid. + # + # The address arrives through the environment rather than being interpolated + # into the script, so that its content is data and never shell. + - name: Validate required variables + env: + HTTP_ADDRESS_GLOAS: ${{ vars.HTTP_ADDRESS_GLOAS }} + run: | + if [ -z "$HTTP_ADDRESS_GLOAS" ]; then + echo "Error: HTTP_ADDRESS_GLOAS variable is not set in repository variables" + exit 1 + fi + + # HTTP_REQUIRE_GLOAS turns every fork gate from a skip into a failure. It is + # not optional and must not be softened: on this endpoint a skip is always a + # bug — a stale address, a predicate typo, a port a docker restart moved — and + # without it the job degrades to an all-skip run and reports green having + # verified none of the Gloas surface. + - name: Run HTTP tests + env: + HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS_GLOAS }} + HTTP_TEST_CONCURRENCY: ${{ vars.HTTP_TEST_CONCURRENCY }} + HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN_GLOAS }} + HTTP_REQUIRE_GLOAS: 'true' + run: go test -v -timeout=10m ./http/... diff --git a/http/epbsproposal_test.go b/http/epbsproposal_test.go index 75a1342d7..53e708e77 100644 --- a/http/epbsproposal_test.go +++ b/http/epbsproposal_test.go @@ -87,6 +87,10 @@ func TestEPBSProposal(t *testing.T) { 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) + // The node produces a block for the slot it is about to propose, so the // target is derived from its head rather than from the wall clock. slot := headSlot(ctx, t, service) + 1 diff --git a/http/executionpayloadenvelope_test.go b/http/executionpayloadenvelope_test.go index 14c02e700..c092adc3e 100644 --- a/http/executionpayloadenvelope_test.go +++ b/http/executionpayloadenvelope_test.go @@ -61,6 +61,12 @@ func TestExecutionPayloadEnvelope(t *testing.T) { // 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 := headSlot(ctx, t, service) - 100 @@ -76,6 +82,13 @@ func TestExecutionPayloadEnvelope(t *testing.T) { // 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}, diff --git a/http/gloasgate_test.go b/http/gloasgate_test.go new file mode 100644 index 000000000..2650a36f5 --- /dev/null +++ b/http/gloasgate_test.go @@ -0,0 +1,186 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + "testing" + + "github.com/attestantio/go-eth2-client/testclients" + "github.com/stretchr/testify/require" +) + +// requireGloas turns every fork gate below from a skip into a failure. It is set +// from HTTP_REQUIRE_GLOAS in TestMain, beside the other HTTP_* knobs. +// +// It exists because the two endpoints this suite runs against want opposite things. +// On the regression endpoint, which is not on Gloas and will not be for years, a +// skip is the correct outcome forever and asserting on it would be noise. On the +// validation devnet a skip is always a bug — a stale address, a predicate typo, an +// enclave port a docker restart moved — and without this the job degrades to an +// all-skip run and reports green having verified nothing. +var requireGloas bool + +// gateT is the part of *testing.T that a fork gate uses. +// +// It is an interface so that the choice below can be tested. The real Skip and +// Fatalf both end the calling goroutine, so a test cannot observe what was done to +// it; a recorder standing in for *testing.T can. +type gateT interface { + Helper() + Skip(args ...any) + Fatalf(format string, args ...any) +} + +// requireKnowsGloas leaves the calling test out unless the node implements the ePBS +// endpoints, which is to say unless its configuration mentions the fork at all. +// +// This is the weaker of the two levels and the right one for a test of pre-Gloas +// behaviour — asking an endpoint to refuse a pre-fork slot needs the endpoint to +// exist, not the chain to have reached the fork. Gating such a test on the head's +// version instead would keep it dark on the regression endpoint forever, where the +// stronger level can never be satisfied. +func requireKnowsGloas(ctx context.Context, t gateT, service any) { + t.Helper() + + if testclients.KnowsGloas(ctx, service) { + return + } + + gate(t, "node does not know gloas (GLOAS_FORK_EPOCH absent)") +} + +// requireOnGloas leaves the calling test out unless the node's chain head is a Gloas +// block, which is what a test operating on head needs. +// +// The head's fork is fetched a second time to name it in the message. That only +// happens on the path where the test is not going to run, and it buys the one thing +// the message is for: a reader can tell a node that has never heard of Gloas from +// one that knows it and has not reached it. +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))) +} + +// gate declines to run the calling test, or fails it if a Gloas node was required. +// +// reason names which of the two levels was not met, and is the whole diagnostic: +// on the gating endpoint it is the only visibility anyone has into that node's +// Gloas posture. +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) +} + +// recordingT stands in for *testing.T so that what a gate does to the test it is +// gating can be asserted instead of happening. +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...) +} + +// TestGateSkipsOrFails is the guard on the one property this whole mechanism rests +// on: HTTP_REQUIRE_GLOAS turns a skip into a failure. +// +// Its value is in what it forbids rather than what it proves. A gated test that +// skips where it should have run is invisible, which is the failure mode the +// inversion exists to catch; leaving that to a maintainer noticing a SKIP line is +// how the unconditional skip in attestationrewards_test.go went unnoticed for an +// unknown period. If a later change softens the failure arm to a warning, or lets +// both arms fire, this is what stops it. +func TestGateSkipsOrFails(t *testing.T) { + tests := []struct { + name string + require bool + skipped string + failed string + }{ + { + // The regression endpoint: a skip here is correct forever. + name: "SkipsWhenNotRequired", + require: false, + skipped: "chain is not on gloas (head is fulu)", + }, + { + // The validation devnet: a skip here is always a bug, so it is not + // available as an outcome. + 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) { + restore := requireGloas + t.Cleanup(func() { requireGloas = restore }) + requireGloas = test.require + + recorder := &recordingT{} + + gate(recorder, "chain is not on gloas (head is fulu)") + + require.Equal(t, test.skipped, recorder.skipped, "skip") + require.Equal(t, test.failed, recorder.failed, "failure") + }) + } +} + +// TestGloasGatesNameTheirLevel pins that the two gates are told apart by their +// message alone, which is what makes a bucket-B test lighting up on the regression +// endpoint observable rather than inferred. +func TestGloasGatesNameTheirLevel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + restore := requireGloas + t.Cleanup(func() { requireGloas = restore }) + requireGloas = false + + // Not a service, so neither question can be answered and both gates decline, + // each naming its own level. + notAService := "http://a-string-is-not-a-service" + + knows := &recordingT{} + requireKnowsGloas(ctx, knows, notAService) + require.Equal(t, "node does not know gloas (GLOAS_FORK_EPOCH absent)", knows.skipped) + + on := &recordingT{} + requireOnGloas(ctx, on, notAService) + require.Equal(t, "chain is not on gloas (head is unknown)", on.skipped) +} diff --git a/http/main_test.go b/http/main_test.go index d4d8cd000..f78dee607 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -58,6 +58,10 @@ func TestMain(m *testing.M) { } testCoordinator = semaphore.NewWeighted(concurrency) + // On the validation devnet a fork-gated test that skips is a bug rather than a + // correct outcome, so this turns those skips into failures. See requireGloas. + requireGloas = strings.EqualFold(os.Getenv("HTTP_REQUIRE_GLOAS"), "true") + if os.Getenv("HTTP_ADDRESS") != "" { // Initialize global HTTP service for all tests to share initGlobalHTTPService() diff --git a/http/payloadattestationpool_test.go b/http/payloadattestationpool_test.go index 6416a4e37..2664d3d27 100644 --- a/http/payloadattestationpool_test.go +++ b/http/payloadattestationpool_test.go @@ -36,7 +36,11 @@ func TestPayloadAttestationPool(t *testing.T) { require.ErrorIs(t, err, client.ErrNoOptions) }) + // NilOpts above is a client-side refusal and needs no fork, so the gate goes + // here rather than on the parent. t.Run("Good", func(t *testing.T) { + requireOnGloas(ctx, t, service) + response, err := service.(client.PayloadAttestationPoolProvider).PayloadAttestationPool(ctx, &api.PayloadAttestationPoolOpts{Slot: &slot}, ) diff --git a/http/ptcduties_test.go b/http/ptcduties_test.go index 700ed8953..ef4a52809 100644 --- a/http/ptcduties_test.go +++ b/http/ptcduties_test.go @@ -83,7 +83,13 @@ func TestPTCDuties(t *testing.T) { require.True(t, errors.Is(err, client.ErrInvalidOptions)) }) + // Only this subtest needs the fork: the two above are refused by the client + // before the network is reached, and they cover that refusal on any node. + // Duties come from the beacon state, so a pre-Gloas state has no committee to + // return — the node answers 500 IncorrectStateVariant rather than 404. t.Run("Good", func(t *testing.T) { + requireOnGloas(ctx, t, service) + response, err := service.(client.PTCDutiesProvider).PTCDuties(ctx, &api.PTCDutiesOpts{ Epoch: epoch, Indices: indices, diff --git a/http/signedbeaconblock_test.go b/http/signedbeaconblock_test.go index 4fdd9ecd6..2b03b7992 100644 --- a/http/signedbeaconblock_test.go +++ b/http/signedbeaconblock_test.go @@ -37,6 +37,8 @@ func TestSignedBeaconBlockGloas(t *testing.T) { service := testService(ctx, t).(client.Service) + requireOnGloas(ctx, t, service) + response, err := service.(client.SignedBeaconBlockProvider).SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{Block: "head"}, ) diff --git a/http/submitpayloadattestationmessages_test.go b/http/submitpayloadattestationmessages_test.go index cf89c58c1..ffed17281 100644 --- a/http/submitpayloadattestationmessages_test.go +++ b/http/submitpayloadattestationmessages_test.go @@ -55,6 +55,8 @@ func TestSubmitPayloadAttestationMessages(t *testing.T) { // messages are looked at, so a rejection that names the signature is // evidence that the header and the body shape were both accepted. t.Run("ReachesServer", func(t *testing.T) { + requireOnGloas(ctx, t, service) + err := service.(client.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, &api.SubmitPayloadAttestationMessagesOpts{ Messages: []*spec.VersionedPayloadAttestationMessage{ diff --git a/http/submitproposal_test.go b/http/submitproposal_test.go index 5e843ce73..d907a4b28 100644 --- a/http/submitproposal_test.go +++ b/http/submitproposal_test.go @@ -56,6 +56,8 @@ func TestSubmitProposalGloas(t *testing.T) { service := testService(ctx, t).(client.Service) + requireOnGloas(ctx, t, service) + jsonService, err := newTestService(ctx, true, http.WithEnforceJSON(true)) require.NoError(t, err) diff --git a/testclients/forkutil.go b/testclients/forkutil.go new file mode 100644 index 000000000..469a39d51 --- /dev/null +++ b/testclients/forkutil.go @@ -0,0 +1,87 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// KnowsGloas reports whether the node's configuration includes the Gloas fork, +// which is to say whether GLOAS_FORK_EPOCH is present in /eth/v1/config/spec. +// +// Presence alone, deliberately: a client that knows Gloas publishes the epoch as +// soon as it has one, typically far in the future, and its ePBS endpoints exist to +// refuse pre-fork requests from that same moment. A far-future epoch therefore +// counts as knowing, and no arithmetic is done on the value. +func KnowsGloas(ctx context.Context, service any) bool { + specProvider, isProvider := service.(interface { + Spec(ctx context.Context, opts *api.SpecOpts) (*api.Response[map[string]any], error) + }) + if !isProvider { + return false + } + + response, err := specProvider.Spec(ctx, &api.SpecOpts{}) + if err != nil || response == nil || response.Data == nil { + return false + } + + _, exists := response.Data["GLOAS_FORK_EPOCH"] + + return exists +} + +// OnGloas reports whether the node's chain head is a Gloas block. +// +// The head's own version rather than current_epoch >= GLOAS_FORK_EPOCH arithmetic: +// one call instead of several spec reads, it is what the gated tests actually +// depend on since they all operate on head, and it cannot disagree with reality +// when a node's configuration and its chain diverge. +func OnGloas(ctx context.Context, service any) bool { + return HeadVersion(ctx, service) == spec.DataVersionGloas.String() +} + +// HeadVersion returns the name of the fork the node's head block belongs to, or +// "unknown" if the node cannot be asked. +// +// It exists so that a caller declining to run can say which fork it found instead +// of only that the fork was wrong, and so that OnGloas above and that message are +// answered by one piece of code rather than two that can disagree. +// +// Because it reads the head block rather than a header, the service must be able to +// decode one: against a non-mainnet preset that means custom spec support, without +// which the compiled-in codec fails on the offsets. A service that cannot decode +// the node's blocks reports "unknown" rather than a fork name, so the caller says +// it could not tell rather than naming a fork the node is not on. +func HeadVersion(ctx context.Context, service any) string { + blockProvider, isProvider := service.(interface { + SignedBeaconBlock(ctx context.Context, opts *api.SignedBeaconBlockOpts) ( + *api.Response[*spec.VersionedSignedBeaconBlock], + error, + ) + }) + if !isProvider { + return "unknown" + } + + response, err := blockProvider.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{Block: "head"}) + if err != nil || response == nil || response.Data == nil { + return "unknown" + } + + return response.Data.Version.String() +} diff --git a/testclients/forkutil_test.go b/testclients/forkutil_test.go new file mode 100644 index 000000000..1953a6435 --- /dev/null +++ b/testclients/forkutil_test.go @@ -0,0 +1,200 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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_test + +import ( + "context" + nethttp "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/testclients" + "github.com/stretchr/testify/require" +) + +// TestGloasPredicatesAgainstLiveNode is the only place the predicates meet a real +// node's wire format, which is the one thing no stub can establish: that +// GLOAS_FORK_EPOCH survives the spec parser as a key of that name, and that the +// head block's version really is reported as gloas. +// +// It skips without HTTP_ADDRESS, as TestNetworkName does — the same seam, and the +// reason the predicates take `any` rather than a concrete client type. +func TestGloasPredicatesAgainstLiveNode(t *testing.T) { + if os.Getenv("HTTP_ADDRESS") == "" { + t.Skip("HTTP_ADDRESS not set") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Custom spec support is not incidental here: OnGloas reads the head block, so + // the service has to be able to decode one, and against a non-mainnet preset + // the compiled-in codec cannot — it fails on the offsets. The http suite builds + // its shared service the same way for the same reason. + service, err := http.New(ctx, + http.WithAddress(os.Getenv("HTTP_ADDRESS")), + http.WithCustomSpecSupport(true), + http.WithAllowDelayedStart(true), + ) + require.NoError(t, err) + + require.True(t, testclients.KnowsGloas(ctx, service), + "GLOAS_FORK_EPOCH absent from the node's spec") + require.True(t, testclients.OnGloas(ctx, service), + "the node's head is not a gloas block") + require.Equal(t, "gloas", testclients.HeadVersion(ctx, service)) +} + +// TestKnowsGloasReadsTheSpec covers the case the validation devnet cannot: a node +// that answers, and whose answer does not mention Gloas. Only a node without the +// fork tells "absent" apart from "present", so the spec comes from a stub rather +// than from hunting down a pre-Gloas endpoint to point at. +// +// That direction is the one that matters. A predicate stuck at false keeps its +// tests dark forever with nothing to surface it; one stuck at true fails loudly on +// the first run. +// +// The present row is not padding — it is what makes the absent row mean anything. +// KnowsGloas answers false both for a spec without the key and for a spec it never +// managed to read, and the stub is reached through a service whose start-up ping +// this stub does not serve. Without a row that reaches the same stub and comes back +// true, an absent-row pass is equally consistent with the spec fetch failing +// outright, which would assert nothing about the key at all. +func TestKnowsGloasReadsTheSpec(t *testing.T) { + tests := []struct { + name string + forkEpochs string + expected bool + }{ + { + name: "GloasAbsent", + forkEpochs: `"ELECTRA_FORK_EPOCH":"0","FULU_FORK_EPOCH":"1"`, + expected: false, + }, + { + // Far future on purpose: a client that knows the fork publishes an + // epoch long before reaching it, and its endpoints exist to refuse + // pre-fork requests from that moment, so this must read as knowing. + name: "GloasFarFuture", + forkEpochs: `"FULU_FORK_EPOCH":"1","GLOAS_FORK_EPOCH":"99999999"`, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := stubSpecService(ctx, t, test.forkEpochs) + + require.Equal(t, test.expected, testclients.KnowsGloas(ctx, service)) + }) + } +} + +// TestGloasPredicatesDegrade pins what the predicates do when there is nothing to +// ask. Both answer "no", and HeadVersion names the fork "unknown" rather than +// returning an empty string, because a caller puts it straight into the message it +// prints when declining to run and "head is " reads as a bug in the harness. +// +// Answering rather than failing is the deliberate part: these run on the gating +// endpoint, where a node that cannot be reached must leave the fork-dependent tests +// out rather than take the suite down with it. +func TestGloasPredicatesDegrade(t *testing.T) { + tests := []struct { + name string + service func(context.Context, *testing.T) any + }{ + { + // Not a beacon client at all: the predicates take `any`, so this is + // a reachable mistake rather than a hypothetical one. + name: "NotAProvider", + service: func(_ context.Context, _ *testing.T) any { + return "http://a-string-is-not-a-service" + }, + }, + { + // A real service whose node is not listening, which is what a stale + // address or a reassigned devnet port looks like from here. + name: "UnreachableNode", + service: func(ctx context.Context, t *testing.T) any { + t.Helper() + + service, err := http.New(ctx, + http.WithAddress("http://127.0.0.1:1"), + http.WithAllowDelayedStart(true), + ) + require.NoError(t, err) + + return service + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := test.service(ctx, t) + + require.False(t, testclients.KnowsGloas(ctx, service)) + require.False(t, testclients.OnGloas(ctx, service)) + require.Equal(t, "unknown", testclients.HeadVersion(ctx, service)) + }) + } +} + +// stubSpecService returns a service whose spec is the given fork epochs and nothing +// else, which is all the predicate reads. SLOTS_PER_EPOCH is included because the +// client's spec parser expects it to be there. +// +// The sync endpoint is served as well, and has to be: it is what New pings on the +// way up, and a service that fails that ping answers every later request with +// "client is not active" instead of calling out at all. A stub serving only the +// spec makes KnowsGloas return false whatever the spec says, which is precisely +// what the present row above exists to catch. +func stubSpecService(ctx context.Context, t *testing.T, forkEpochs string) *http.Service { + t.Helper() + + 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 are what New asks for on the way up, and both have to + // answer for the service to come up active. + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"head_slot":"32","sync_distance":"0",` + + `"is_syncing":false,"is_optimistic":false,"el_offline":false}}`)) + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"stub/v0.0.0"}}`)) + case "/eth/v1/config/spec": + _, _ = w.Write([]byte(`{"data":{"SLOTS_PER_EPOCH":"32",` + forkEpochs + `}}`)) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + service, err := http.New(ctx, + http.WithAddress(server.URL), + http.WithAllowDelayedStart(true), + ) + require.NoError(t, err) + + return service.(*http.Service) +} From 48d759b3d025765c1f1be7fa3a8bdc0e2061e1a4 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 17:55:59 +0200 Subject: [PATCH 24/37] skip the pre-Gloas envelope test on a node that knows the fork but serves no route The first CI run of the fork gates found the bucket-B premise to be false on the regression endpoint. Seven of the eight gates skipped as intended and the three non-fork failures were gone, leaving exactly one failure: ExecutionPayloadEnvelope/PreGloasSlotIsNotSentinel, which did not skip. It did not skip because KnowsGloas was true. That node already publishes GLOAS_FORK_EPOCH in its configuration and has no ePBS route behind it, so the assumption the gate was built on -- that a client advertising the fork also serves its endpoints -- does not hold. Every request to the endpoint is a bare 404 with no pre-fork refusal inside it, so there is nothing for the test to assert, and it failed on the 404 mapping onto the sentinel it is checking against. The gate itself behaved correctly; what was wrong was reading "knows the fork" as "implements the endpoints". Key decisions: - The escape is withheld once the chain is on Gloas, which is the part that makes this safe rather than a hole in the test. On a Gloas node a 404 for a pre-fork slot is the very defect being guarded against -- it is what maps onto the sentinel -- so treating 404 as "no route yet" everywhere would have deleted the guard while appearing to preserve it. Off Gloas the same status can only mean the route is absent. - KnowsGloas is left exactly as it was, and no third predicate was added. The weaker gate is still correct for this test and still lights it up the day the regression endpoint's client ships the route; what was missing is that a node can sit between the two states for a long time, and that is a property of this one endpoint rather than of the fork question. - It routes through the same gate helper as the fork levels, so HTTP_REQUIRE_GLOAS turns it into a failure too. On the validation devnet the route is served and this branch is unreachable; if it ever fires there, a node that knows Gloas and has withdrawn the endpoint is worth failing over. - The api.Error extraction moved above the sentinel assertion, since the status has to be known before deciding whether to assert at all. No assertion was removed. Files changed: - modified: http/executionpayloadenvelope_test.go (one conditional, one import) Verified against three endpoints, the last two synthesised because no real node occupies the states they represent: - the Gloas devnet, route served: passes, escape not taken, and its neighbour PastSlotIsSentinel still passes - the fork stripped from the config: skips on the KnowsGloas gate as before, so the new branch has not displaced the old one - knows the fork, head withheld, route 404 -- the endpoint's situation reproduced: skips naming the route, and fails instead under HTTP_REQUIRE_GLOAS Also verified: go build, go vet, gofmt clean; ./custom-gcl 0 issues; 2395 tests green with no address; zero skips among the gated tests against the devnet. Blockers / notes for next iteration: - This weakens what the gating job proves about the ePBS surface until that endpoint's client ships the routes: the test will skip rather than run, and the skip message says which of the two reasons applies. That is visible rather than silent, which is the most this endpoint can offer today. - Worth reconsidering at ADR level whether the bucket-B level should be "serves the route" rather than "knows the fork", now that the two are known to be separated by a long interval on a real node. Filed rather than decided here. --- http/executionpayloadenvelope_test.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/http/executionpayloadenvelope_test.go b/http/executionpayloadenvelope_test.go index c092adc3e..af9973b83 100644 --- a/http/executionpayloadenvelope_test.go +++ b/http/executionpayloadenvelope_test.go @@ -20,6 +20,7 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/go-eth2-client/testclients" "github.com/stretchr/testify/require" ) @@ -94,10 +95,23 @@ func TestExecutionPayloadEnvelope(t *testing.T) { BeaconBlockRoot: phase0.Root{0x01}, }) require.Error(t, err) - require.NotErrorIs(t, err, client.ErrNoExecutionPayloadEnvelope) 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) }) } From 7b9467afa8840efb894eeda5db271177ea909502 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 18:50:08 +0200 Subject: [PATCH 25/37] give the gloas http job a bandwidth budget, not a hang timeout The advisory Gloas job ended in "panic: test timed out after 10m0s" with TestValidators 4m12s in and a couple of tests still to run, so the report it produced was silent about those rather than red or green about them. The cause is scale, not a hang. That devnet carries 503,477 validators, so the validator set is a 222MB response -- measured at 47.7s and 4.7MB/s from a workstation -- and the debug state is 81MB. A runner's link is several times slower again: TestBeaconState took 82s there against roughly a quarter of that locally. Nothing is stuck; the suite simply cannot pull that much inside 10m. Key decisions: - 25m, and only on the advisory job. The other endpoint finishes in 2m49s and keeps its 10m. That one is also the gating job, where a genuine hang should still fail the pull request quickly rather than after a further quarter hour. - The comment says the number is a bandwidth budget, so that a later reader does not tighten it as though it guarded against hangs, and names the validator count as the thing that will invalidate it. - Nothing else was folded in. The job stays red, for reasons that are now itemised in a comment on the pull request: of 15 failing subtests one is a defect in this library (the gloas Builder version wire form), three are an optional header Prysm omits from SSZ v4 proposal responses, six are node-side 500s, and five are expected on any Gloas network. - The whole-collection tests were left alone. Narrowing them to explicit ids would cut the transfer but interacts with HTTP_REQUIRE_GLOAS, which exists precisely so that a skip on this endpoint is always a bug, and it trades away the mainnet-scale decode coverage this endpoint uniquely provides. That is a judgement call about someone else's shared infrastructure, not a timeout fix. Files changed: - modified: .github/workflows/http-tests.yml (one value, plus the comment that explains it) Verified: the YAML parses and both jobs survive it; only the advisory job's timeout moved; the gating job keeps 10m and its continue-on-error setting is untouched. Blockers / notes for next iteration: - 25m is inferred, not observed. The suite has never completed end to end on a runner, so the figure comes from where the 10m run died plus the measured payload sizes. The next run on this branch is what confirms it, and if TestValidators is slower than estimated this will need raising again. - The transfer cost per pull request is now several hundred MB from shared infrastructure. Raising the timeout accepts that cost rather than answering it. --- .github/workflows/http-tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index ac98e8f10..41fa88e5e 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -77,10 +77,18 @@ jobs: # bug — a stale address, a predicate typo, a port a docker restart moved — and # without it the job degrades to an all-skip run and reports green having # verified none of the Gloas surface. + # The timeout is a bandwidth budget rather than a hang detector, which is why + # it is so much larger than the endpoint above needs. This devnet is + # mainnet-scale — half a million validators — so the suite's wall clock is + # dominated by whole-collection fetches: the validator set is a 222MB response + # and the debug state 81MB, and a runner's link is several times slower than a + # workstation's. At 10m the run died inside TestValidators with a couple of + # tests still to go, so the report it produced was silent about them rather + # than green. Revisit when that validator count grows again. - name: Run HTTP tests env: HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS_GLOAS }} HTTP_TEST_CONCURRENCY: ${{ vars.HTTP_TEST_CONCURRENCY }} HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN_GLOAS }} HTTP_REQUIRE_GLOAS: 'true' - run: go test -v -timeout=10m ./http/... + run: go test -v -timeout=25m ./http/... From a2e1f05cfa32144e9143d4f7879fa0815b5719ec Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 19:13:15 +0200 Subject: [PATCH 26/37] stop three validator rows pulling the whole set to prove a state id resolves TestValidators' Genesis, Head, Finalized and Justified rows each fetched every validator on the network, and the shared assertion block never looks at the set's size: those rows leave expectedValidators unset, so all they check is that the state id resolved and the response is well formed. The whole-set fetch was incidental to what they prove, and on a mainnet-scale network it is what the test spends its time on. Measured against the mainnet-preset endpoint, where the validator set is 135.8MB gzipped: the four rows were 94.4s of the test's 121.7s. Three of them now ask for five indices instead, and those three go from 69.43s to 0.70s. The same three on the Gloas endpoint are 0.25s to 2.33s against 23.10s for the row still fetching everything -- same process, same session, same upstream, so roughly ninety-fold. Key decisions: - Head keeps the whole set, deliberately. It is the only place in the suite that decodes a validator list at mainnet scale, around 1.1M entries, and that is worth one fetch per run. Narrowing all four would have deleted that coverage silently, which is a different decision from cutting waste; the comment says so in the file, so it is not "finished off" later by someone tidying up. - Index-narrowing rather than skipping. A skip would have been cheaper still, but the Gloas endpoint's job sets HTTP_REQUIRE_GLOAS precisely so that a skip there is always a bug, and adding a legitimate skip category to that endpoint turns a bright line into a judgement call. A narrowed request produces no skip at all. - Five indices, not the 500-odd that ManyValidatorIndices uses. That row already covers a large explicit index list; these three only need enough to prove the state resolved. - ExitedValidators is untouched. Its cost is a server-side state filter, which is the thing it exists to test. Files changed: - modified: http/validators_test.go (three rows, plus the comment recording the measurements and why Head is the exception) Verified live against both endpoints the CI workflow uses, before and after. Mainnet preset, Lighthouse v8.2.1: 121.69s to 71.70s, all subtests still passing, including both network-pinned pubkey rows, which proves network detection is unaffected. Gloas endpoint, Prysm v7.1.7: 43.11s, all passing or skipped as before. gofmt and go vet clean -- vet matters here because the lint gate skips test files. Blockers / notes for next iteration: - Do not read the 121.69s to 71.70s delta as the saving. Head was 13.6s slower in the second run than the first, because that endpoint's wall clock varies independently of anything in this diff. The defensible figure is the 69.43s to 0.70s on the three rows that changed. - The same whole-collection pattern very likely exists elsewhere in the suite. TestValidatorBalances pulls 503,477 balances on the Gloas endpoint and was not examined; the debug beacon state, at 81MB, has no narrowing available at all. --- http/validators_test.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/http/validators_test.go b/http/validators_test.go index 0f599df9b..d5236cded 100644 --- a/http/validators_test.go +++ b/http/validators_test.go @@ -47,9 +47,21 @@ func TestValidators(t *testing.T) { opts: &api.ValidatorsOpts{State: "0x000102030405060708090a0b0c0d0e0f10111213145161718191a1b1c1d1e1f"}, expectedErrorCode: 400, }, + // These four rows exist to prove state-id resolution -- that "0", head, + // finalized and justified each resolve and return a well-formed set -- and + // nothing below asserts anything about the set's size. Three of them + // therefore ask for a handful of indices instead of the whole validator + // set, which on a mainnet-scale network is what this test spends its time + // on: measured against hoodi, the four unnarrowed fetches were 94s of the + // test's 122s at 135MB gzipped each, and that transfer proved nothing the + // narrowed form does not. + // + // Head deliberately keeps the whole set. It is the only place in the suite + // that decodes a validator list at mainnet scale, which is worth one fetch; + // narrowing all four would have deleted that coverage silently. { name: "Genesis", - opts: &api.ValidatorsOpts{State: "0"}, + opts: &api.ValidatorsOpts{State: "0", Indices: []phase0.ValidatorIndex{0, 1, 2, 3, 4}}, }, { name: "Head", @@ -57,11 +69,11 @@ func TestValidators(t *testing.T) { }, { name: "Finalized", - opts: &api.ValidatorsOpts{State: "finalized"}, + opts: &api.ValidatorsOpts{State: "finalized", Indices: []phase0.ValidatorIndex{0, 1, 2, 3, 4}}, }, { name: "Justified", - opts: &api.ValidatorsOpts{State: "justified"}, + opts: &api.ValidatorsOpts{State: "justified", Indices: []phase0.ValidatorIndex{0, 1, 2, 3, 4}}, }, { name: "ManyValidatorIndices", From 6746c2604bfc69c0d3f17669b9fc1d7ce6a525dc Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 19:13:53 +0200 Subject: [PATCH 27/37] correct why the gloas http job needs a long timeout The comment added with that timeout blamed runner bandwidth: "a runner's link is several times slower than a workstation's". Measurement does not support it. The other endpoint in this same workflow serves a validator set 2.7x larger -- 135.8MB gzipped against 50.0MB -- fetches it four times, and its job finishes in 2m49s. If runner bandwidth were the constraint, that job would be the one in trouble. The actual cause is which upstream the load balancer assigns. That address fronts ~47 nodes with IP-sticky sessions, and the same commit against it 23 minutes apart spent 1m34s on a single TestValidators subtest and exceeded a 10m budget, then ran the whole test in 11.37s and the suite in 103s. Two things rule out the alternatives: head advanced continuously across both runs, so the devnet had not respun, and the x-dugtrio-endpoint-name response header changed between them, from prysm-erigon-1 to prysm-reth-1. Key decisions: - The 25m value is unchanged. The reasoning was wrong, not the number, and the conclusion it supports is stronger under the real cause: variance that large is exactly what a generous ceiling is for. - The comment now names the header to check before investigating a slow job, which is the actionable part a future reader needs and the old text did not give. - The mistake is recorded rather than quietly overwritten. A reader who saw the old reasoning should be able to tell it was tested and dropped, not lost in an edit. - The 222MB figure is gone. It was measured without compression, and Go's HTTP client requests gzip, so the client only ever saw ~50MB. Files changed: - modified: .github/workflows/http-tests.yml (comment only) Verified: the YAML parses and both jobs survive it, with the advisory job still at 25m and continue-on-error, and the gating job still at 10m. No behaviour changes. Blockers / notes for next iteration: - The variance is unexplained beyond attributing it to the upstream. Which node is slow, and why, is not established -- a request-level record of that header across a whole run would be needed, and the job does not capture one. --- .github/workflows/http-tests.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index 41fa88e5e..219490559 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -77,14 +77,24 @@ jobs: # bug — a stale address, a predicate typo, a port a docker restart moved — and # without it the job degrades to an all-skip run and reports green having # verified none of the Gloas surface. - # The timeout is a bandwidth budget rather than a hang detector, which is why - # it is so much larger than the endpoint above needs. This devnet is - # mainnet-scale — half a million validators — so the suite's wall clock is - # dominated by whole-collection fetches: the validator set is a 222MB response - # and the debug state 81MB, and a runner's link is several times slower than a - # workstation's. At 10m the run died inside TestValidators with a couple of - # tests still to go, so the report it produced was silent about them rather - # than green. Revisit when that validator count grows again. + # The timeout absorbs endpoint variance rather than detecting a hang, which is + # why it is so much larger than the endpoint above needs. That address is a + # dugtrio load balancer over ~47 nodes with IP-sticky sessions, so a run gets + # whichever upstream it is assigned and they are not equally fast: the same + # commit against the same address, 23 minutes apart, spent 1m34s on one + # TestValidators subtest and blew a 10m budget, then did the whole test in + # 11.37s with the suite finishing in 103s. The chain had not respun — head + # advanced continuously — and only x-dugtrio-endpoint-name changed, from + # prysm-erigon-1 to prysm-reth-1. + # + # So a slow run here is a bad draw, not a regression, and 10m truncated the + # report rather than failing it: the run died inside TestValidators with tests + # still to go, saying nothing about them either way. Check that response + # header before investigating a slow job. + # + # Note this is not runner bandwidth, which an earlier version of this comment + # blamed: the endpoint above serves a validator set 2.7x larger, four times + # over, inside a 2m49s job. - name: Run HTTP tests env: HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS_GLOAS }} From a42c6e15684d2762ae8e6c3be05939825a60df60 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 19:41:58 +0200 Subject: [PATCH 28/37] say which node answered, before the suite starts Nothing in the suite's output named the server, and on the Gloas validation devnet the server is not a fixed thing. That address is a load balancer over ~47 nodes spanning six consensus implementations, and the caller cannot choose its upstream: neither ?client=prysm, ?endpoint=..., X-Dugtrio-Endpoint nor X-Dugtrio-Client changes where a request lands. The implementations disagree about which routes they serve and how they word errors, so the same commit against the same address produced 15 failing subtests on one run and a different 20 on another, with the chain healthy throughout -- finalized two epochs behind head, 58 peers. A reader diffing those two runs could not tell a regression from a different client having answered. This prints one line before any test runs: endpoint identity: status=200 version="teku/v26.7.0+33-g..." upstream=teku-ethrex-1 Key decisions: - It runs before the service is constructed, not after, so the address still identifies itself when the client cannot come up against it -- which is when knowing what is there matters most. - Best effort, always. Every failure is printed and then ignored, and the timeout is 10s: a diagnostic must never decide whether the suite runs, nor add meaningfully to a run that is about to fail anyway. - A raw net/http request rather than the client's own NodeVersion provider, which is the reason for the nethttp import alias. The provider returns the version but not response headers, and the header is the half that identifies the upstream. - The absent-header case reads "none (direct connection)" rather than being left blank, because that is a fact worth stating: on such an address the client cannot change between runs, so this whole class of confusion does not apply. Files changed: - modified: http/main_test.go (one function, its call site, two imports) Verified against all four branches. Load-balanced: names teku/v26.7.0 at teku-ethrex-1, which is also the direct evidence that this pool serves the suite a non-prysm client. Direct node with a token: Lighthouse/v8.2.1, "none (direct connection)". Dead address: reports connection refused, suite still ok, 0.303s, no hang. No token against an endpoint that needs one: status=404 version="unknown", which incidentally surfaces an auth failure that otherwise appears only as "client is not active", because that endpoint answers 404 rather than 401 when unauthenticated. gofmt and go vet clean; vet matters here because the lint gate skips test files. Blockers / notes for next iteration: - Verifying this needs -count=1. Without it, go test served runs 2 and 3 from cache and replayed run 1's stdout, so a changed HTTP_ADDRESS appeared to produce the identical identity line and a dead address appeared to answer in 0.186s. Any future check of live-endpoint behaviour in this package should assume the same trap. - This makes the non-determinism visible; it does not fix it. Pinning a client needs either a per-client load balancer hostname from the devnet operators or harness support for the HTTP Basic auth the per-node URLs require, since the token path here formats a Bearer header. --- http/main_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/http/main_test.go b/http/main_test.go index f78dee607..08e1f34ba 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -16,7 +16,9 @@ package http_test import ( "context" "encoding/hex" + "encoding/json" "fmt" + nethttp "net/http" "os" "strconv" "strings" @@ -63,12 +65,78 @@ func TestMain(m *testing.M) { requireGloas = strings.EqualFold(os.Getenv("HTTP_REQUIRE_GLOAS"), "true") if os.Getenv("HTTP_ADDRESS") != "" { + // Before the service, so that the address still identifies itself when the + // client cannot come up against it -- which is exactly when knowing what is + // there matters most. + logEndpointIdentity() + // Initialize global HTTP service for all tests to share initGlobalHTTPService() os.Exit(m.Run()) } } +// logEndpointIdentity prints which node the suite is about to talk to, once, before any +// test runs. +// +// It exists because an address is not necessarily a node. The Gloas validation devnet is +// reached through a load balancer over ~47 nodes spanning six consensus implementations, +// the caller cannot choose its upstream (no query parameter or header selects one), and +// the implementations do not agree on which routes they serve or how they word their +// errors. So the same commit against the same address produced 15 failures on one run and +// a different 20 on another. Without this line, a reader diffing two runs has no way to +// tell a regression from a different client having answered, because nothing else in the +// output names the server. +// +// Best effort by design: a diagnostic must never decide whether the suite runs, so every +// failure here is reported and then ignored. The timeout is deliberately short for the +// same reason -- this must not add meaningfully to a run that is about to fail anyway. +func logEndpointIdentity() { + endpoint := strings.TrimSuffix(os.Getenv("HTTP_ADDRESS"), "/") + "/eth/v1/node/version" + + req, err := nethttp.NewRequest(nethttp.MethodGet, endpoint, nil) + if err != nil { + fmt.Printf("endpoint identity: unavailable (%v)\n", err) + + return + } + + if token := os.Getenv("HTTP_BEARER_TOKEN"); token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + } + + resp, err := (&nethttp.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + fmt.Printf("endpoint identity: unavailable (%v)\n", err) + + return + } + defer resp.Body.Close() + + version := "unknown" + + var body struct { + Data struct { + Version string `json:"version"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Data.Version != "" { + version = body.Data.Version + } + + // Set by dugtrio, the load balancer fronting the ethpandaops devnets. Its absence + // means a direct connection to a single node, which is itself the thing worth + // knowing -- on such an address the client identity below cannot change between + // runs. + upstream := resp.Header.Get("X-Dugtrio-Endpoint-Name") + if upstream == "" { + upstream = "none (direct connection)" + } + + fmt.Printf("endpoint identity: status=%d version=%q upstream=%s\n", resp.StatusCode, version, upstream) +} + // newTestService creates an HTTP service against HTTP_ADDRESS, authenticating with // HTTP_BEARER_TOKEN when one is set. customSpecSupport picks which branch the SSZ // decoders take, so a caller that needs a mode other than the suite's default gets it From 8441a64e445d114d61b022c873f6bbc3562cb880 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 31 Jul 2026 22:06:59 +0200 Subject: [PATCH 29/37] fix epbs proposal guard rejecting a conformant builder-bid response assertEPBSProposalMatchesRequest compared execution_payload_included against the requested include_payload for equality, but per the v4 propose-block spec that parameter only governs self-building: a node serving an external builder's bid always returns the payload excluded, regardless of what was asked. The symmetric check rejected that legitimate response as inconsistent, so any caller requesting include_payload=true (required for multi-node, DV and failover setups) could never complete a builder-bid slot. Narrowed the check to the one direction that is actually a fault: a node including a payload that was not requested. A node excluding one is now accepted either way. Updated the doc comments on the check and on EPBSProposalOpts.IncludePayload (both branches) to describe the corrected, asymmetric semantics, since a self-building request can now also come back excluded when an external builder wins the slot. TestEPBSProposalRejectsADisagreeingNode's fixture provoked the guard using the direction the fix legalizes, so it now provokes the remaining invalid direction instead. Added BuilderBidExcludesRequestedPayload, decoding a real wire response to prove the newly-accepted case survives the whole path, not just the check in isolation. Files changed: http/epbsproposal.go, http/epbsproposal_internal_test.go, http/epbsguardwiring_internal_test.go, api/epbsproposalopts.go Verified with gosilent test ./... and a live-devnet run showing no regression versus a HEAD baseline (identical failure set plus the one new passing test); go build, go vet and the repo's lint gate all clean. --- api/epbsproposalopts.go | 16 +++++++++--- http/epbsguardwiring_internal_test.go | 24 ++++++++++-------- http/epbsproposal.go | 16 ++++++------ http/epbsproposal_internal_test.go | 36 +++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/api/epbsproposalopts.go b/api/epbsproposalopts.go index 701f12d5b..a31ee523a 100644 --- a/api/epbsproposalopts.go +++ b/api/epbsproposalopts.go @@ -38,13 +38,21 @@ type EPBSProposalOpts struct { // IncludePayload selects whether a self-built proposal carries its execution // payload envelope and blobs. // - // True is stateless: 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. + // 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(). + // 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: diff --git a/http/epbsguardwiring_internal_test.go b/http/epbsguardwiring_internal_test.go index a9d75271d..cb8dbf179 100644 --- a/http/epbsguardwiring_internal_test.go +++ b/http/epbsguardwiring_internal_test.go @@ -64,8 +64,8 @@ func disagreeingService(ctx context.Context, t *testing.T, handler http.HandlerF } // TestEPBSProposalRejectsADisagreeingNode verifies that the request-consistency -// guard is reached from EPBSProposal, by asking a node that answers with the -// payload-inclusion mode that was not requested. +// 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() @@ -73,21 +73,25 @@ func TestEPBSProposalRejectsADisagreeingNode(t *testing.T) { reveal := phase0.BLSSignature{0x0a, 0x0b, 0x0c} - // Always answers payload-excluded, whatever was asked for. + // 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, func(w http.ResponseWriter, _ *http.Request) { - block := validEPBSBeaconBlock() - block.Slot = slot - block.Body.RANDAOReveal = reveal + contents := validEPBSBlockContents() + contents.Block.Slot = slot + contents.Block.Body.RANDAOReveal = reveal - data, err := json.Marshal(block) + data, err := json.Marshal(contents) require.NoError(t, err) w.Header().Set("Content-Type", "application/json") w.Header().Set("Eth-Consensus-Version", "gloas") - fmt.Fprintf(w, `{"version":"gloas","execution_payload_included":false,"data":%s}`, data) + fmt.Fprintf(w, `{"version":"gloas","execution_payload_included":true,"data":%s}`, data) }) - includePayload := true + includePayload := false _, err := s.EPBSProposal(ctx, &api.EPBSProposalOpts{ Slot: slot, @@ -95,7 +99,7 @@ func TestEPBSProposalRejectsADisagreeingNode(t *testing.T) { IncludePayload: &includePayload, }) require.ErrorIs(t, err, client.ErrInconsistentResult) - require.ErrorContains(t, err, "execution payload included false; expected true") + require.ErrorContains(t, err, "execution payload included true; expected false") } // TestExecutionPayloadEnvelopeRejectsADisagreeingNode verifies that the block-root diff --git a/http/epbsproposal.go b/http/epbsproposal.go index ae3861385..7fcd89207 100644 --- a/http/epbsproposal.go +++ b/http/epbsproposal.go @@ -100,13 +100,15 @@ func (s *Service) assertEPBSProposalMatchesRequest(proposal *api.VersionedEPBSPr ) } - // The payload-inclusion mode must be the one that was asked for. The two are - // not interchangeable — excluded means the block can only be published - // through the node that produced it — so a node answering with the other mode - // has silently changed where the caller is able to publish. Nodes have been - // observed treating the parameter as optional and supplying their own default, - // which is what makes this worth checking rather than assuming. - if proposal.ExecutionPayloadIncluded != *opts.IncludePayload { + // Only the node volunteering a payload is a fault. Including one that was + // not asked for changes where the caller is able to publish, which is not + // the node's to decide. The other direction is what the spec requires: + // include_payload only governs self-building, and a block built on an + // external builder's bid comes back alone whatever was asked for, since the + // node does not hold the builder's payload. So an excluded payload can no + // longer be told apart from a node quietly defaulting the parameter, and the + // builder path is worth more than catching that. + 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), diff --git a/http/epbsproposal_internal_test.go b/http/epbsproposal_internal_test.go index 5fad33817..10ece9cf4 100644 --- a/http/epbsproposal_internal_test.go +++ b/http/epbsproposal_internal_test.go @@ -467,9 +467,11 @@ func TestEPBSProposalFromResponse(t *testing.T) { } // TestAssertEPBSProposalMatchesRequest covers the checks made on what the node -// returned. None of them can be provoked against a live node — a node that +// 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{} @@ -508,10 +510,10 @@ func TestAssertEPBSProposalMatchesRequest(t *testing.T) { require.ErrorContains(t, err, "for slot 60300; expected 60301") }) - // The mode decides where the block can be published, so a node answering with - // the other one has changed that without saying so. Worth checking rather - // than assuming: a node has been observed defaulting the parameter when it is - // absent, despite the spec marking it required. + // 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() @@ -522,6 +524,30 @@ func TestAssertEPBSProposalMatchesRequest(t *testing.T) { 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} From 55bfa283a606118133aded24e77c475ff92ff310 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Sat, 1 Aug 2026 09:43:30 +0200 Subject: [PATCH 30/37] fix VersionedSignedProposal.ExecutionBlockHash for Gloas proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecutionBlockHash() and its internal helper assertExecutionPayloadPresent() had no DataVersionGloas arm and fell through to ErrUnsupportedVersion, so the accessor errored for every post-Gloas proposal. Added the Gloas arm to both, mirroring the pattern already used by the file's other five fork-dispatch methods (no blinded branch, since there is no blinded proposal post-Gloas). Also adds api/versionedsignedproposal_test.go, which did not exist before this change, covering the success path and every link of the new assertExecutionPayloadPresent nil chain. Files changed: - api/versionedsignedproposal.go - api/versionedsignedproposal_test.go (new) Note for next iteration: assertExecutionPayloadPresent's Fulu-blinded arm has a separate, pre-existing nil-deref (missing .Body/.ExecutionPayloadHeader checks its sibling arms have) — out of scope here, filed as a follow-up. --- api/versionedsignedproposal.go | 17 ++++ api/versionedsignedproposal_test.go | 138 ++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 api/versionedsignedproposal_test.go diff --git a/api/versionedsignedproposal.go b/api/versionedsignedproposal.go index 18ebf5dfc..2e49088a4 100644 --- a/api/versionedsignedproposal.go +++ b/api/versionedsignedproposal.go @@ -265,6 +265,11 @@ func (v *VersionedSignedProposal) ExecutionBlockHash() (phase0.Hash32, error) { } return v.Fulu.SignedBlock.Message.Body.ExecutionPayload.BlockHash, nil + case spec.DataVersionGloas: + // No blinded arm: there is no blinded proposal post-Gloas. The bid the + // proposer committed to stands in for the payload and the payload header + // the earlier arms read. + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlockHash, nil default: return phase0.Hash32{}, ErrUnsupportedVersion } @@ -534,6 +539,18 @@ func (v *VersionedSignedProposal) assertExecutionPayloadPresent() error { return ErrDataMissing } } + case spec.DataVersionGloas: + // No blinded arm to consider: there is no blinded proposal post-Gloas. + // The chain runs one field deeper than assertMessagePresent's Gloas arm + // because the execution block hash lives in the bid, so a block whose bid + // is absent cannot answer for one. + if v.Gloas == nil || + v.Gloas.Message == nil || + v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return ErrDataMissing + } default: return ErrUnsupportedVersion } diff --git a/api/versionedsignedproposal_test.go b/api/versionedsignedproposal_test.go new file mode 100644 index 000000000..382e76127 --- /dev/null +++ b/api/versionedsignedproposal_test.go @@ -0,0 +1,138 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedSignedProposalExecutionBlockHash verifies that a Gloas proposal +// reports the execution block hash its proposer committed to. Post-Gloas that +// hash lives in the execution payload bid rather than in a payload or a payload +// header, so a wrapper that recognised every earlier fork but not this one would +// refuse every single post-ePBS proposal. +// +// The absent-data cases matter for a subtler reason: they must fail as missing +// data, not as an unsupported version. A caller distinguishes "this node gave +// me an incomplete proposal" from "this library cannot read this fork" by that +// error alone, and the second answer is wrong for a fork the wrapper carries a +// field for. +func TestVersionedSignedProposalExecutionBlockHash(t *testing.T) { + blockHash := phase0.Hash32{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + } + + tests := []struct { + name string + proposal *api.VersionedSignedProposal + expected phase0.Hash32 + err error + }{ + { + name: "Gloas", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{ + BlockHash: blockHash, + }, + }, + }, + }, + }, + }, + expected: blockHash, + }, + { + // The bid is the only place a Gloas proposal carries an execution + // block hash, so its absence is the case that must not report the + // zero hash as a successful answer. + name: "GloasNoBid", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }, + }, + }, + err: api.ErrDataMissing, + }, + { + // A signed bid whose message is absent carries a signature over + // nothing; the hash has to be read from the message, not the wrapper. + name: "GloasNoBidMessage", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{}, + }, + }, + }, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoBody", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{}, + }, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoMessage", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{}, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoBlock", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + }, + err: api.ErrDataMissing, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.proposal.ExecutionBlockHash() + if test.err != nil { + require.ErrorIs(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, got) + } + }) + } +} From a3111893d25624819500e374cec995b924f371a1 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Mon, 3 Aug 2026 13:00:29 +0200 Subject: [PATCH 31/37] add BodyRoot/ParentRoot/StateRoot to VersionedEPBSProposal A proposer needs these three to sign an ePBS proposal, but the versioned wrapper only exposed Slot/RandaoReveal/Value/contents accessors. All three funnel through the existing block() helper so the version, arm-selection and nil-checks can't drift from the other accessors: ParentRoot/StateRoot read the fields directly, BodyRoot takes the HTR of the block body (not the block) since that's what the proposer signature covers. --- api/versionedepbsproposal.go | 35 ++++ api/versionedepbsproposal_test.go | 280 ++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) diff --git a/api/versionedepbsproposal.go b/api/versionedepbsproposal.go index f1abdba1a..c594231ff 100644 --- a/api/versionedepbsproposal.go +++ b/api/versionedepbsproposal.go @@ -63,6 +63,41 @@ func (v *VersionedEPBSProposal) RandaoReveal() (phase0.BLSSignature, error) { 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. +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") + } + + return block.Body.HashTreeRoot() +} + // 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. diff --git a/api/versionedepbsproposal_test.go b/api/versionedepbsproposal_test.go index 5737f2427..277c3e9ba 100644 --- a/api/versionedepbsproposal_test.go +++ b/api/versionedepbsproposal_test.go @@ -141,6 +141,286 @@ func TestVersionedEPBSProposalRandaoReveal(t *testing.T) { 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: "unknown 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: "unknown 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)) + + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: block, + }).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)) + + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: block}, + }).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") + }) + + 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: "unknown version", + }, + } + + for _, test := range errTests { + t.Run(test.name, func(t *testing.T) { + _, err := test.proposal.BodyRoot() + require.EqualError(t, err, test.err) + }) + } +} + // 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 From fc18498b2165617b893ed8296ca4af44d6c09eaf Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Mon, 3 Aug 2026 13:24:34 +0200 Subject: [PATCH 32/37] use ErrUnsupportedVersion for the unknown-version case block()/contents()'s genuinely-unrecognized-version branch returned an ad-hoc "unknown version" string, unlike every other Versioned* wrapper in this package, which returns the shared ErrUnsupportedVersion sentinel for this case. Leaves the rest of the file's descriptive nil-arm errors as is. --- api/versionedepbsproposal.go | 4 ++-- api/versionedepbsproposal_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/versionedepbsproposal.go b/api/versionedepbsproposal.go index c594231ff..d826134bf 100644 --- a/api/versionedepbsproposal.go +++ b/api/versionedepbsproposal.go @@ -211,7 +211,7 @@ func (v *VersionedEPBSProposal) block() (*gloas.BeaconBlock, error) { return v.Gloas, nil default: - return nil, errors.New("unknown version") + return nil, ErrUnsupportedVersion } } @@ -234,6 +234,6 @@ func (v *VersionedEPBSProposal) contents() (*apiv1gloas.BlockContents, error) { return v.GloasContents, nil default: - return nil, errors.New("unknown version") + return nil, ErrUnsupportedVersion } } diff --git a/api/versionedepbsproposal_test.go b/api/versionedepbsproposal_test.go index 277c3e9ba..83aba9724 100644 --- a/api/versionedepbsproposal_test.go +++ b/api/versionedepbsproposal_test.go @@ -94,7 +94,7 @@ func TestVersionedEPBSProposalSlot(t *testing.T) { proposal: &api.VersionedEPBSProposal{ Version: spec.DataVersion(99), }, - err: "unknown version", + err: "unsupported version", }, } @@ -208,7 +208,7 @@ func TestVersionedEPBSProposalParentRoot(t *testing.T) { proposal: &api.VersionedEPBSProposal{ Version: spec.DataVersion(99), }, - err: "unknown version", + err: "unsupported version", }, } @@ -290,7 +290,7 @@ func TestVersionedEPBSProposalStateRoot(t *testing.T) { proposal: &api.VersionedEPBSProposal{ Version: spec.DataVersion(99), }, - err: "unknown version", + err: "unsupported version", }, } @@ -409,7 +409,7 @@ func TestVersionedEPBSProposalBodyRoot(t *testing.T) { proposal: &api.VersionedEPBSProposal{ Version: spec.DataVersion(99), }, - err: "unknown version", + err: "unsupported version", }, } From 3f63d541fe9d1eef902d360ee9cbc26055be88cd Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Mon, 3 Aug 2026 17:23:33 +0200 Subject: [PATCH 33/37] accept the gloas payload-availability vote in attestation data verifyAttestationData required data.Index to be 0 for every slot at or after the electra fork. Gloas repurposes that field as a one-bit vote on whether the attester sees the attested block's execution payload in the canonical chain, so the check discarded every payload-FULL answer a conformant node gave and left a validator client unable to attest on those slots, with no way to opt out. At or after GLOAS_FORK_EPOCH the assertion is now the bound that process_attestation itself applies, data.Index < 2. Before the fork nothing changes: the electra-era clamp to 0 is untouched, and an index of 1 at a pre-gloas slot is still an inconsistent result. Decisions: - The fork is answered as a predicate on a slot, isGloasSlot, rather than as the fork's first slot beside the existing calculateElectraSlot. The epoch and whether the node has one at all are only meaningful together, and an unknown fork has no first slot to return, so a caller comparing against a slot without consulting a companion flag would admit every slot rather than none. - A node without GLOAS_FORK_EPOCH in its configuration answers false rather than failing. A client publishes the epoch only once it has one, so treating the missing key the way calculateElectraSlot treats its own would break this endpoint on every node that predates the fork. - The committee_index query parameter is left as it is. beacon-APIs says it SHOULD be omitted from gloas onward, but lighthouse 400s without it, so sending it is the interoperable choice until a node is seen accepting the omission. Verified by a table-driven test that answers from its own httptest server: a node cannot be asked for a payload-FULL vote on demand, since which vote it returns is decided by its own fork choice, and the pre-fork half of the gate cannot be asked of the same node at all. Eight consecutive live requests to a gloas devnet answered 0, which is the same branch every pre-gloas answer takes. Each of the five rows was checked to be load-bearing by mutating the guard and confirming exactly that row failed. Files changed: - http/attestationdata.go: the gloas arm of the index assertion, and isGloasSlot - http/attestationdata_test.go: the synthetic-node regression test Notes for the next iteration: isGloasSlot and calculateElectraSlot each read the cached spec separately, so a pre-gloas verification now takes two cached reads where it took one. Folding them into one read means changing calculateElectraSlot's signature, which the additive-only constraint wants left alone for a saving of nanoseconds on a path with no I/O. --- http/attestationdata.go | 59 ++++++++++++- http/attestationdata_test.go | 163 ++++++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/http/attestationdata.go b/http/attestationdata.go index dd77ac2fe..f52971bf8 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 41eb38c65..5a58113f6 100644 --- a/http/attestationdata_test.go +++ b/http/attestationdata_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,17 +15,178 @@ package http_test import ( "context" + "encoding/json" "errors" + "fmt" + nethttp "net/http" + "net/http/httptest" "strings" "testing" "time" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +// attestationDataService returns a service whose every request is answered from +// memory: the two endpoints a service needs to come up, the configuration given by +// specEntries, and data as the answer to every attestation-data request. +// +// No live node can stand in for it. Which vote a conformant node returns is decided by +// its own fork choice, so a caller cannot ask for the payload-FULL one: eight +// consecutive requests to a Gloas devnet answered 0 every time, and a request that is +// answered 0 exercises the same branch as every pre-Gloas answer. The other half of the +// gate cannot be asked of that node at all, since a chain is on one side of a fork or +// the other. Synthesising the node makes both sides reachable on demand, and reachable +// on an endpoint that has never heard of Gloas. +func attestationDataService(ctx context.Context, + t *testing.T, + specEntries string, + data *phase0.AttestationData, +) client.Service { + t.Helper() + + // Marshalled here rather than in the handler because a failure must be reported + // from the test's own goroutine. + encoded, err := json.Marshal(data) + require.NoError(t, err) + + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + // Both of these, or New leaves the service inactive and every call below + // returns ErrNotActive without ever reaching the wire. + case "/eth/v1/node/version": + fmt.Fprint(w, `{"data":{"version":"stub"}}`) + case "/eth/v1/node/syncing": + fmt.Fprint(w, `{"data":{"head_slot":"1","sync_distance":"0","is_syncing":false,"is_optimistic":false,"el_offline":false}}`) + case "/eth/v1/config/spec": + fmt.Fprintf(w, `{"data":{%s}}`, specEntries) + case "/eth/v1/validator/attestation_data": + fmt.Fprintf(w, `{"data":%s}`, encoded) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + service, err := http.New(ctx, http.WithAddress(server.URL)) + require.NoError(t, err) + + return service +} + +// TestAttestationDataGloasPayloadVote covers the fork gate on the index assertion. +// +// Gloas repurposes AttestationData.index as a payload-availability vote, so the +// Electra-era expectation that it is 0 turns a conformant node's payload-FULL answer +// into ErrInconsistentResult and leaves the validator unable to attest on those +// slots. What has to hold is that the two eras are told apart: bounded to {0, 1} at +// or after the fork, pinned to 0 before it. +func TestAttestationDataGloasPayloadVote(t *testing.T) { + ctx := context.Background() + + const slotsPerEpoch = 32 + // Gloas at epoch 100, so slot 3200 is the fork's first slot and 3199 its last + // pre-fork one. + const gloasForkEpoch = "100" + const gloasSlot = phase0.Slot(3200) + + tests := []struct { + name string + // Absent from the configuration when empty, as it is on every node that + // does not know the fork. + gloasForkEpoch string + slot phase0.Slot + index phase0.CommitteeIndex + err string + }{ + { + // The payload-EMPTY vote, which is also every pre-Gloas value, so this + // is the case the electra-era expectation already admitted. + name: "PayloadEmptyVoteAccepted", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 0, + }, + { + // The payload-FULL vote: the case that was discarded, leaving a + // validator client unable to attest on any slot where its node + // legitimately answered 1. + name: "PayloadFullVoteAccepted", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 1, + }, + { + // Two is not a payload status. The bound stays asserted, so widening + // the vote does not amount to no longer checking the field. + name: "VoteAboveOneRejected", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 2, + err: "attestation data payload availability vote 2; expected 0 or 1", + }, + { + // One slot earlier the field is still a committee index that electra + // zeroed, so the same answer is still inconsistent. + name: "LastPreGloasSlotStillClamped", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot - 1, + index: 1, + err: "attestation data for committee index 1; expected 0", + }, + { + // A node that has never heard of the fork cannot be voting on payloads, + // whatever the slot number, so its answer is held to electra's rule. + name: "NodeThatDoesNotKnowGloasStillClamped", + slot: gloasSlot, + index: 1, + err: "attestation data for committee index 1; expected 0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Electra at epoch 0 so that the pre-Gloas expectation in force is + // always the Electra-era clamp to 0, which is what the Gloas arm has + // to displace. + specEntries := fmt.Sprintf(`"SLOTS_PER_EPOCH":"%d","ELECTRA_FORK_EPOCH":"0"`, slotsPerEpoch) + if test.gloasForkEpoch != "" { + specEntries += fmt.Sprintf(`,"GLOAS_FORK_EPOCH":"%s"`, test.gloasForkEpoch) + } + + // Slot and Index are the only fields the guard under test reads; the + // checkpoints are present because the response would not decode + // without them, not because their contents matter. + service := attestationDataService(ctx, t, specEntries, &phase0.AttestationData{ + Slot: test.slot, + Index: test.index, + Source: &phase0.Checkpoint{}, + Target: &phase0.Checkpoint{}, + }) + + response, err := service.(client.AttestationDataProvider).AttestationData(ctx, &api.AttestationDataOpts{ + Slot: test.slot, + }) + + if test.err != "" { + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, test.err) + + return + } + + require.NoError(t, err) + require.Equal(t, test.index, response.Data.Index) + }) + } +} + func TestAttestationData(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() From 7529615d9eb3d3072e78769b9f98a0a8db8eb233 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 4 Aug 2026 16:24:04 +0200 Subject: [PATCH 34/37] fix: decode Gloas attestations in pool --- http/aggregateattestation_internal_test.go | 38 +++---- http/attestationpool.go | 43 +++++++- http/attestationpool_internal_test.go | 116 +++++++++++++++++++++ 3 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 http/attestationpool_internal_test.go diff --git a/http/aggregateattestation_internal_test.go b/http/aggregateattestation_internal_test.go index 4592738a6..5c2761c2f 100644 --- a/http/aggregateattestation_internal_test.go +++ b/http/aggregateattestation_internal_test.go @@ -21,27 +21,29 @@ import ( "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) { diff --git a/http/attestationpool.go b/http/attestationpool.go index 1e9b809fc..70556eeb4 100644 --- a/http/attestationpool.go +++ b/http/attestationpool.go @@ -24,6 +24,7 @@ import ( "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -73,7 +74,27 @@ 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.DataVersionGloas: + gloasData, gloasMetadata, decodeErr := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*gloas.Attestation{}) + metadata = gloasMetadata + err = decodeErr + data = make([]*spec.VersionedAttestation, len(gloasData)) + for i, datum := range gloasData { + data[i] = &spec.VersionedAttestation{ + Version: spec.DataVersionGloas, + Gloas: datum, + } + } + default: + data, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), []*spec.VersionedAttestation{}) + } if err != nil { return nil, err } @@ -119,6 +140,10 @@ func verifyAttestationPool(opts *api.AttestationPoolOpts, data []*spec.Versioned if err := verifyElectraAttestation(opts, datum.Fulu); err != nil { return err } + case spec.DataVersionGloas: + if err := verifyGloasAttestation(opts, datum.Gloas); err != nil { + return err + } default: return errors.New("unsupported attestation version") } @@ -140,7 +165,19 @@ 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 verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) +} + +func verifyGloasAttestation(opts *api.AttestationPoolOpts, data *gloas.Attestation) error { + return verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) +} + +func verifyPostElectraAttestation( + opts *api.AttestationPoolOpts, + data *phase0.AttestationData, + committeeBits interface{ BitIndices() []int }, +) error { + if opts.Slot != nil && data.Slot != *opts.Slot { return errors.New("attestation data not for requested slot") } @@ -150,7 +187,7 @@ func verifyElectraAttestation(opts *api.AttestationPoolOpts, data *electra.Attes return nil } - for _, committeeIndex := range data.CommitteeBits.BitIndices() { + for _, committeeIndex := range committeeBits.BitIndices() { if phase0.CommitteeIndex(committeeIndex) == *opts.CommitteeIndex { // We have a match. return nil diff --git a/http/attestationpool_internal_test.go b/http/attestationpool_internal_test.go new file mode 100644 index 000000000..25c74a5a0 --- /dev/null +++ b/http/attestationpool_internal_test.go @@ -0,0 +1,116 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +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) + } + }) + } +} From 57f80076cd036bf0717bbbf53e9ce279107299d9 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 4 Aug 2026 16:37:01 +0200 Subject: [PATCH 35/37] remove stale indexed attestation validation comments --- spec/gloas/indexedattestation.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/spec/gloas/indexedattestation.go b/spec/gloas/indexedattestation.go index 3183e6b97..e3f202f67 100644 --- a/spec/gloas/indexedattestation.go +++ b/spec/gloas/indexedattestation.go @@ -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 { From c2ab3be92fb717dc9ff6633c9e6a686d50cc4f56 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 4 Aug 2026 17:09:35 +0200 Subject: [PATCH 36/37] test: cover inherited Gloas SSZ roots Add PySpec-derived roots and byte-stable SSZ round trips for inherited Gloas containers and their aggregate, slashing, and block paths.\n\nExercise minimal-preset dynamic decoding alongside the mainnet path.\n\nFiles: spec/gloas/sszroot_test.go\n\nNote: official ssz_static vector validation remains separately tracked. --- spec/gloas/sszroot_test.go | 253 +++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 spec/gloas/sszroot_test.go diff --git a/spec/gloas/sszroot_test.go b/spec/gloas/sszroot_test.go new file mode 100644 index 000000000..f6b22caa7 --- /dev/null +++ b/spec/gloas/sszroot_test.go @@ -0,0 +1,253 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/hex" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/stretchr/testify/require" +) + +// The expected roots in this file were independently calculated with the +// Ethereum consensus-specs v1.7.0-alpha.12 PySpec at commit +// 84454a9d57f4f49f74c78e9f375347baf30474f2. That implementation uses +// eth-remerkleable rather than this repository's generated dynamic-ssz codec. +// This independent coverage complements, but does not replace, official +// v1.7.0-alpha.12 consensus-spec vector validation. +// +// Each fixture below mirrors the named PySpec container's non-zero fields. The +// literals deliberately make a generated-code regression fail even if the same +// regression affects both this package's MarshalSSZ and HashTreeRoot methods. +type sszRootable interface { + MarshalSSZ() ([]byte, error) + UnmarshalSSZ([]byte) error + HashTreeRoot() ([32]byte, error) +} + +func TestInheritedContainerSSZRoots(t *testing.T) { + tests := []struct { + name string + value sszRootable + newValue func() sszRootable + root string + }{ + { + name: "Attestation", + value: testAttestation(), + newValue: func() sszRootable { return &gloas.Attestation{} }, + root: "7f14183aef36f97fd2e90104f3afb1e8eeef426d9c2613fae882131db3bb820b", + }, + { + name: "IndexedAttestation", + value: testIndexedAttestation(1), + newValue: func() sszRootable { return &gloas.IndexedAttestation{} }, + root: "683e085a809f52ba767d8e50033c25684a71708fe063224ad8ef293ecc8b55b6", + }, + { + name: "ExecutionPayload", + value: testExecutionPayload(), + newValue: func() sszRootable { return &gloas.ExecutionPayload{} }, + root: "f1a113a895e90bce01951250d9858cf9d71ace6c4355f92dd409be321975d8bc", + }, + { + name: "ExecutionRequests", + value: testExecutionRequests(), + newValue: func() sszRootable { return &gloas.ExecutionRequests{} }, + root: "b43a532aa4dbf7e7b6b5cfa4b6cc1c54941be8e5f4eda56d965a534a8daec3b4", + }, + { + name: "BeaconBlockBody", + value: testBeaconBlockBody(), + newValue: func() sszRootable { return &gloas.BeaconBlockBody{} }, + root: "85ce69a9ad9f3abfca93126bd85b98a005e0405112b70f0bfb3e245affcf3598", + }, + { + name: "BeaconState", + value: testBeaconState(), + newValue: func() sszRootable { return &gloas.BeaconState{} }, + root: "4e2b7050ea9041ac17e2308892b23f24a895302f73ae662c12641fdfb3b38431", + }, + { + name: "SignedAggregateAndProof", + value: testSignedAggregateAndProof(), + newValue: func() sszRootable { return &gloas.SignedAggregateAndProof{} }, + root: "71473a74b9b3273a783edc7ebdeb412ccdbfc034dcd860dd7f77b4d506996f38", + }, + { + name: "AttesterSlashing", + value: testAttesterSlashing(), + newValue: func() sszRootable { return &gloas.AttesterSlashing{} }, + root: "cbb503af73492f1ab7dbbd53ae3aa83f781e99a12015eb8b072871be5630c60c", + }, + { + name: "SignedBeaconBlock", + value: testSignedBeaconBlock(), + newValue: func() sszRootable { return &gloas.SignedBeaconBlock{} }, + root: "99fff2530b1b3e26ab697f29dde7ae9f8ee0bbfdd07255861be1b33f5864a79c", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := test.value.MarshalSSZ() + require.NoError(t, err) + + decoded := test.newValue() + require.NoError(t, decoded.UnmarshalSSZ(encoded)) + + roundTripped, err := decoded.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, encoded, roundTripped) + + root, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, rootFromHex(t, test.root), root) + }) + } +} + +// TestAttestationSSZCustomPreset exercises the dynamic path used by callers +// with custom spec support. The minimal preset has four committees per slot, +// so its committee bitvector is one byte rather than mainnet's eight. +func TestAttestationSSZCustomPreset(t *testing.T) { + attestation := &gloas.Attestation{ + AggregationBits: bitfield.Bitlist{0x19}, + CommitteeBits: bitfield.Bitvector64{0}, + } + dynamicSSZ := dynssz.NewDynSsz(map[string]any{ + "MAX_COMMITTEES_PER_SLOT": uint64(4), + }) + + encoded, err := dynamicSSZ.MarshalSSZ(attestation) + require.NoError(t, err) + // Independently encoded by the v1.7.0-alpha.12 minimal PySpec. + require.Len(t, encoded, 230) + + var decoded gloas.Attestation + require.NoError(t, dynamicSSZ.UnmarshalSSZ(&decoded, encoded)) + require.Equal(t, []byte{0x19}, []byte(decoded.AggregationBits)) + require.Equal(t, []byte{0}, []byte(decoded.CommitteeBits)) + + root, err := dynamicSSZ.HashTreeRoot(&decoded) + require.NoError(t, err) + require.Equal(t, rootFromHex(t, "2c107e8672069142b8bfa7924cdb90be0fd9f7ebb61c6157f8f40fc608df5bf5"), root) +} + +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 +} From 5a524431408cd8c122194cb69d33f9c6b1c9d1c1 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 5 Aug 2026 11:24:50 +0200 Subject: [PATCH 37/37] fix: tighten gloas follow-up quality --- .github/workflows/http-tests.yml | 51 +-------------- api/versionedsignedproposal.go | 17 +---- http/attestationpool.go | 2 +- http/submitexecutionpayloadenvelope.go | 26 ++++---- multi/events.go | 14 ++-- multi/events_test.go | 88 ++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 90 deletions(-) diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index 219490559..4d262b66d 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -27,10 +27,6 @@ jobs: exit 1 fi - # No -run selection: the whole suite runs, and the fork-dependent tests leave - # themselves out by asking this node what it is. Selecting by name here would - # put the choice in YAML, where it drifts against test names and the first new - # ePBS test whose name misses the pattern lands in this gating job silently. - name: Run HTTP tests env: HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS }} @@ -38,16 +34,9 @@ jobs: HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN }} run: go test -v -timeout=10m ./http/... - # The same suite against a Gloas devnet, which is the only place the ePBS surface - # can be exercised at all: the endpoint above is on an earlier fork, and on a - # different preset, so between them they cover the shipped surface and the new one. - # - # Advisory for now. That devnet is interim and frequently respun, so a failure here - # must not block a pull request — but it must not be ignorable either, which is what - # the two settings below are for. http-tests-gloas: + if: ${{ vars.HTTP_ADDRESS_GLOAS != '' }} runs-on: ubuntu-24.04 - continue-on-error: true steps: - name: Checkout code uses: actions/checkout@v4 @@ -57,44 +46,6 @@ jobs: with: go-version: '1.25.2' - # Deliberately fatal rather than skipped. With no address the suite's TestMain - # returns before running anything and the job would report success having - # executed no tests at all — the silent-green outcome this job exists to avoid. - # - # The address arrives through the environment rather than being interpolated - # into the script, so that its content is data and never shell. - - name: Validate required variables - env: - HTTP_ADDRESS_GLOAS: ${{ vars.HTTP_ADDRESS_GLOAS }} - run: | - if [ -z "$HTTP_ADDRESS_GLOAS" ]; then - echo "Error: HTTP_ADDRESS_GLOAS variable is not set in repository variables" - exit 1 - fi - - # HTTP_REQUIRE_GLOAS turns every fork gate from a skip into a failure. It is - # not optional and must not be softened: on this endpoint a skip is always a - # bug — a stale address, a predicate typo, a port a docker restart moved — and - # without it the job degrades to an all-skip run and reports green having - # verified none of the Gloas surface. - # The timeout absorbs endpoint variance rather than detecting a hang, which is - # why it is so much larger than the endpoint above needs. That address is a - # dugtrio load balancer over ~47 nodes with IP-sticky sessions, so a run gets - # whichever upstream it is assigned and they are not equally fast: the same - # commit against the same address, 23 minutes apart, spent 1m34s on one - # TestValidators subtest and blew a 10m budget, then did the whole test in - # 11.37s with the suite finishing in 103s. The chain had not respun — head - # advanced continuously — and only x-dugtrio-endpoint-name changed, from - # prysm-erigon-1 to prysm-reth-1. - # - # So a slow run here is a bad draw, not a regression, and 10m truncated the - # report rather than failing it: the run died inside TestValidators with tests - # still to go, saying nothing about them either way. Check that response - # header before investigating a slow job. - # - # Note this is not runner bandwidth, which an earlier version of this comment - # blamed: the endpoint above serves a validator set 2.7x larger, four times - # over, inside a 2m49s job. - name: Run HTTP tests env: HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS_GLOAS }} diff --git a/api/versionedsignedproposal.go b/api/versionedsignedproposal.go index 2e49088a4..1d2ecce8e 100644 --- a/api/versionedsignedproposal.go +++ b/api/versionedsignedproposal.go @@ -48,14 +48,7 @@ type VersionedSignedProposal struct { ElectraBlinded *apiv1electra.SignedBlindedBeaconBlock Fulu *apiv1fulu.SignedBlockContents FuluBlinded *apiv1electra.SignedBlindedBeaconBlock - // Gloas is a plain signed block, 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 here. - // - // There is deliberately no GloasBlinded counterpart. Post-Gloas a proposer - // commits to an execution payload bid rather than to a payload, so there is - // nothing to withhold and no blinded schema to carry. - Gloas *gloas.SignedBeaconBlock + Gloas *gloas.SignedBeaconBlock } // AssertPresent throws an error if the expected proposal @@ -113,9 +106,6 @@ func (v *VersionedSignedProposal) AssertPresent() error { return errors.New("blinded fulu proposal not present") } case spec.DataVersionGloas: - // No blinded arm to consider: there is no blinded proposal post-Gloas. - // A proposal marked blinded here is a caller mistake rather than a - // second shape to look for, so it is refused outright. if v.Blinded { return errors.New("gloas proposals are never blinded") } @@ -173,7 +163,6 @@ func (v *VersionedSignedProposal) Slot() (phase0.Slot, error) { return v.Fulu.SignedBlock.Message.Slot, nil case spec.DataVersionGloas: - // No blinded arm: there is no blinded proposal post-Gloas. return v.Gloas.Message.Slot, nil default: return 0, ErrUnsupportedVersion @@ -540,10 +529,8 @@ func (v *VersionedSignedProposal) assertExecutionPayloadPresent() error { } } case spec.DataVersionGloas: - // No blinded arm to consider: there is no blinded proposal post-Gloas. // The chain runs one field deeper than assertMessagePresent's Gloas arm - // because the execution block hash lives in the bid, so a block whose bid - // is absent cannot answer for one. + // 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/http/attestationpool.go b/http/attestationpool.go index 70556eeb4..98b98f157 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 diff --git a/http/submitexecutionpayloadenvelope.go b/http/submitexecutionpayloadenvelope.go index 7cb9ce041..1be2030b5 100644 --- a/http/submitexecutionpayloadenvelope.go +++ b/http/submitexecutionpayloadenvelope.go @@ -52,32 +52,28 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, versioned := opts.SignedExecutionPayloadEnvelope - var contents any - - switch versioned.Version { - case spec.DataVersionGloas: - 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: + if versioned.Version != spec.DataVersionGloas { return errors.Join( fmt.Errorf("unsupported envelope version %s", versioned.Version), client.ErrInvalidOptions, ) } + if versioned.Gloas == nil { + return errors.Join(errors.New("no gloas envelope supplied"), client.ErrInvalidOptions) + } + + contents := &apiv1gloas.SignedExecutionPayloadEnvelopeContents{ + SignedExecutionPayloadEnvelope: versioned.Gloas, + KZGProofs: nonNilKZGProofs(opts.KZGProofs), + Blobs: nonNilBlobs(opts.Blobs), + } body, contentType, err := s.marshalRequestBody(ctx, contents) if err != nil { return err } - return s.postExecutionPayloadEnvelope(ctx, &opts.Common, versioned.Version, + return s.postExecutionPayloadEnvelope(ctx, &opts.Common, spec.DataVersionGloas, opts.BroadcastValidation, body, contentType) } diff --git a/multi/events.go b/multi/events.go index 7db653e30..473bf3a10 100644 --- a/multi/events.go +++ b/multi/events.go @@ -17,6 +17,7 @@ import ( "context" "fmt" "math/rand" + "slices" "time" consensusclient "github.com/attestantio/go-eth2-client" @@ -26,11 +27,6 @@ import ( ) // Events feeds requested events with the given topics to the supplied handler. -// -// The caller must not mutate opts after calling this. Each handler supplied is captured for the -// lifetime of the subscription, so replacing one afterwards has no effect. The topics slice is -// retained by reference, and is read again whenever a client that was not synced at this point -// later comes into service, which can be long after this returns. func (s *Service) Events(ctx context.Context, opts *api.EventsOpts, ) error { @@ -73,7 +69,7 @@ func (s *Service) Events(ctx context.Context, if !isProvider { ah.log.Error(). Str("address", ah.address). - Strs("topics", opts.Topics). + Strs("topics", ah.clientOpts.Topics). Msg("Not a node syncing provider") return @@ -83,7 +79,7 @@ func (s *Service) Events(ctx context.Context, if err != nil { ah.log.Error(). Str("address", ah.address). - Strs("topics", opts.Topics). + Strs("topics", ah.clientOpts.Topics). Err(err). Msg("Failed to obtain sync state from node") @@ -97,7 +93,7 @@ func (s *Service) Events(ctx context.Context, if err := c.(consensusclient.EventsProvider).Events(ctx, ah.clientOpts); err != nil { ah.log.Error(). Str("address", ah.address). - Strs("topics", opts.Topics). + Strs("topics", ah.clientOpts.Topics). Err(err). Msg("Failed to set up events handler") } @@ -139,7 +135,7 @@ func newActiveHandler(s *Service, log zerolog.Logger, address string, opts *api. sub := &api.EventsOpts{ Common: opts.Common, - Topics: opts.Topics, + Topics: slices.Clone(opts.Topics), } ah.clientOpts = sub diff --git a/multi/events_test.go b/multi/events_test.go index 3465403cb..f9476e26e 100644 --- a/multi/events_test.go +++ b/multi/events_test.go @@ -15,7 +15,9 @@ package multi_test import ( "context" + "errors" "reflect" + "strings" "sync/atomic" "testing" "time" @@ -27,9 +29,16 @@ import ( "github.com/attestantio/go-eth2-client/multi" "github.com/attestantio/go-eth2-client/testclients" "github.com/rs/zerolog" + zerologger "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" ) +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(data []byte) (int, error) { + return f(data) +} + func TestEvents(t *testing.T) { ctx := context.Background() @@ -209,6 +218,85 @@ func TestEventsPassesOptionsToClient(t *testing.T) { 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, + })) + + topics[0] = "attestation" + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + require.Equal(t, []string{"head", "block"}, clientOpts().Topics) +} + +// TestEventsOwnsTopicsForDeferredClients confirms that a later sync check retains the requested +// topics when the caller reuses the options slice after Events returns. +func TestEventsOwnsTopicsForDeferredClients(t *testing.T) { + ctx := context.Background() + + logs := make(chan string, 1) + originalLogger := zerologger.Logger + zerologger.Logger = zerolog.New(writerFunc(func(data []byte) (int, error) { + logs <- string(data) + + return len(data), nil + })) + defer func() { + zerologger.Logger = originalLogger + }() + + primary, _ := mockCapturingEvents(ctx, t, "mock 1") + deferred, _ := 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 nil, errors.New("failed to obtain sync state") + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.ErrorLevel), + 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, + })) + + <-syncStarted + topics[0] = "attestation" + close(continueSync) + + var event string + require.Eventually(t, func() bool { + select { + case event = <-logs: + return strings.Contains(event, "Failed to obtain sync state from node") + default: + return false + } + }, time.Second, 10*time.Millisecond, "deferred sync failure was not logged") + require.Contains(t, event, `"topics":["head","block"]`) +} + // 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