From a01aa5d8a9396752c5e694088b10a4d94ab18c77 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 13:37:25 +0300 Subject: [PATCH 001/150] exporter: drop dead vendor sszgen directive (file is hand-maintained) The directive's ../../vendor/ includes no longer exist, and no available sszgen (ferranbt/fastssz v1.0.0 in tool.mod, public v0.1.3, or the prysmaticlabs fork) can regenerate the file: CommitteeDutyTrace.Role is int32 (no SSZ type) and SignerData.ValidatorIdx is a named-uint64 slice the current tool mishandles. Replace it with a note so go generate no longer fails here. --- exporter/traces/model.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/exporter/traces/model.go b/exporter/traces/model.go index 736aceec78..a319986b28 100644 --- a/exporter/traces/model.go +++ b/exporter/traces/model.go @@ -7,7 +7,14 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" ) -//go:generate sszgen -include ../../vendor/github.com/attestantio/go-eth2-client/spec/phase0,../../vendor/github.com/ssvlabs/ssv-spec/types,../../vendor/github.com/ssvlabs/ssv-spec/qbft --path model.go --objs ValidatorDutyTrace,CommitteeDutyTrace,DiskMsg +// NOTE: model_encoding.go is HAND-MAINTAINED — `go generate` cannot regenerate it. +// Every available sszgen (ferranbt/fastssz v1.0.0 in tool.mod, public v0.1.3, and the +// prysmaticlabs fork) rejects CommitteeDutyTrace.Role (spectypes.RunnerRole is int32, and +// SSZ has no signed-int type) and mishandles SignerData.ValidatorIdx +// ([]phase0.ValidatorIndex, a named-uint64 slice the current tool emits as []uint64). +// The committed encoding came from an older/custom generator (int32->uint64 cast, +// make([]NamedType, n)); edit model_encoding.go by hand when these structs change. +// Objects, once a compatible sszgen exists again: ValidatorDutyTrace, CommitteeDutyTrace, DiskMsg. type ValidatorDutyTrace struct { ConsensusTrace From 5b7e5c0322c6b10e8bd47567b900920dea1f8736 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 13:37:26 +0300 Subject: [PATCH 002/150] gloas: add node-side ePBS wire types and Gloas fork placeholder New protocol/v2/types/gloas package: SIP #94 canonical wire constants (runner/beacon roles 7/8/9, partial-sig types, beacon domains 0x0B/0C/0D); GloasBeaconVote (BeaconVote + AttestationDataIndex, fixed 120-byte SSZ); and a DataVersionGloas placeholder + IsGloas helper until go-eth2-client ships Gloas. Covered by round-trip, cross-fork-decode, wire-constant, and placeholder tests. --- protocol/v2/types/gloas/beacon_vote.go | 32 +++++ .../v2/types/gloas/beacon_vote_encoding.go | 122 ++++++++++++++++++ protocol/v2/types/gloas/beacon_vote_test.go | 45 +++++++ protocol/v2/types/gloas/constants.go | 53 ++++++++ protocol/v2/types/gloas/constants_test.go | 28 ++++ protocol/v2/types/gloas/fork.go | 17 +++ protocol/v2/types/gloas/fork_test.go | 18 +++ 7 files changed, 315 insertions(+) create mode 100644 protocol/v2/types/gloas/beacon_vote.go create mode 100644 protocol/v2/types/gloas/beacon_vote_encoding.go create mode 100644 protocol/v2/types/gloas/beacon_vote_test.go create mode 100644 protocol/v2/types/gloas/constants.go create mode 100644 protocol/v2/types/gloas/constants_test.go create mode 100644 protocol/v2/types/gloas/fork.go create mode 100644 protocol/v2/types/gloas/fork_test.go diff --git a/protocol/v2/types/gloas/beacon_vote.go b/protocol/v2/types/gloas/beacon_vote.go new file mode 100644 index 0000000000..084001f5ab --- /dev/null +++ b/protocol/v2/types/gloas/beacon_vote.go @@ -0,0 +1,32 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// The phase0 --include is resolved from the module graph (via `go list -m`), so it +// is not pinned to a go-eth2-client version and survives dependency bumps. +// Regenerate with `go generate ./...`. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./beacon_vote.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs GloasBeaconVote" + +// GloasBeaconVote is the Gloas (ePBS) variant of spectypes.BeaconVote — the value the +// committee runner agrees on for Gloas slots. It mirrors BeaconVote (BlockRoot + +// Source/Target checkpoints, 112 bytes) and appends the BN-supplied +// AttestationData.Index for a fixed 120-byte SSZ encoding (field order per SIP #94). +// The 120B-vs-112B length difference makes a cross-fork decode fail cleanly. +type GloasBeaconVote struct { + BlockRoot phase0.Root `ssz-size:"32"` + Source *phase0.Checkpoint + Target *phase0.Checkpoint + AttestationDataIndex phase0.CommitteeIndex +} + +// Encode returns the SSZ-encoded GloasBeaconVote. +func (g *GloasBeaconVote) Encode() ([]byte, error) { + return g.MarshalSSZ() +} + +// Decode reads an SSZ-encoded GloasBeaconVote. +func (g *GloasBeaconVote) Decode(data []byte) error { + return g.UnmarshalSSZ(data) +} diff --git a/protocol/v2/types/gloas/beacon_vote_encoding.go b/protocol/v2/types/gloas/beacon_vote_encoding.go new file mode 100644 index 0000000000..fca00d8802 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_vote_encoding.go @@ -0,0 +1,122 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: f1f3e11890862710cd9e5d35f97f7aba983941f2854040100fc9bceb52253f3e +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the GloasBeaconVote object +func (g *GloasBeaconVote) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(g) +} + +// MarshalSSZTo ssz marshals the GloasBeaconVote object to a target array +func (g *GloasBeaconVote) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'BlockRoot' + dst = append(dst, g.BlockRoot[:]...) + + // Field (1) 'Source' + if g.Source == nil { + g.Source = new(phase0.Checkpoint) + } + if dst, err = g.Source.MarshalSSZTo(dst); err != nil { + return + } + + // Field (2) 'Target' + if g.Target == nil { + g.Target = new(phase0.Checkpoint) + } + if dst, err = g.Target.MarshalSSZTo(dst); err != nil { + return + } + + // Field (3) 'AttestationDataIndex' + dst = ssz.MarshalUint64(dst, uint64(g.AttestationDataIndex)) + + return +} + +// UnmarshalSSZ ssz unmarshals the GloasBeaconVote object +func (g *GloasBeaconVote) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 120 { + return ssz.ErrSize + } + + // Field (0) 'BlockRoot' + copy(g.BlockRoot[:], buf[0:32]) + + // Field (1) 'Source' + if g.Source == nil { + g.Source = new(phase0.Checkpoint) + } + if err = g.Source.UnmarshalSSZ(buf[32:72]); err != nil { + return err + } + + // Field (2) 'Target' + if g.Target == nil { + g.Target = new(phase0.Checkpoint) + } + if err = g.Target.UnmarshalSSZ(buf[72:112]); err != nil { + return err + } + + // Field (3) 'AttestationDataIndex' + g.AttestationDataIndex = phase0.CommitteeIndex(ssz.UnmarshallUint64(buf[112:120])) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the GloasBeaconVote object +func (g *GloasBeaconVote) SizeSSZ() (size int) { + size = 120 + return +} + +// HashTreeRoot ssz hashes the GloasBeaconVote object +func (g *GloasBeaconVote) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(g) +} + +// HashTreeRootWith ssz hashes the GloasBeaconVote object with a hasher +func (g *GloasBeaconVote) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'BlockRoot' + hh.PutBytes(g.BlockRoot[:]) + + // Field (1) 'Source' + if g.Source == nil { + g.Source = new(phase0.Checkpoint) + } + if err = g.Source.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'Target' + if g.Target == nil { + g.Target = new(phase0.Checkpoint) + } + if err = g.Target.HashTreeRootWith(hh); err != nil { + return + } + + // Field (3) 'AttestationDataIndex' + hh.PutUint64(uint64(g.AttestationDataIndex)) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the GloasBeaconVote object +func (g *GloasBeaconVote) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(g) +} diff --git a/protocol/v2/types/gloas/beacon_vote_test.go b/protocol/v2/types/gloas/beacon_vote_test.go new file mode 100644 index 0000000000..f4aaf44bab --- /dev/null +++ b/protocol/v2/types/gloas/beacon_vote_test.go @@ -0,0 +1,45 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestGloasBeaconVote_SSZRoundTrip(t *testing.T) { + vote := &GloasBeaconVote{ + BlockRoot: phase0.Root{0x01, 0x02, 0x03}, + Source: &phase0.Checkpoint{Epoch: 10, Root: phase0.Root{0xaa}}, + Target: &phase0.Checkpoint{Epoch: 11, Root: phase0.Root{0xbb}}, + AttestationDataIndex: 1, + } + + // Fixed 120-byte encoding (112B BeaconVote layout + 8B AttestationDataIndex). + require.Equal(t, 120, vote.SizeSSZ()) + + enc, err := vote.Encode() + require.NoError(t, err) + require.Len(t, enc, 120) + + var decoded GloasBeaconVote + require.NoError(t, decoded.Decode(enc)) + require.Equal(t, vote.BlockRoot, decoded.BlockRoot) + require.Equal(t, vote.Source, decoded.Source) + require.Equal(t, vote.Target, decoded.Target) + require.Equal(t, vote.AttestationDataIndex, decoded.AttestationDataIndex) + + htr1, err := vote.HashTreeRoot() + require.NoError(t, err) + htr2, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, htr1, htr2) +} + +// TestGloasBeaconVote_CrossForkDecodeFails asserts a 120B Gloas vote cannot be +// decoded from a 112B (pre-Gloas BeaconVote) buffer, so fork selection by length +// fails cleanly. +func TestGloasBeaconVote_CrossForkDecodeFails(t *testing.T) { + var v GloasBeaconVote + require.Error(t, v.Decode(make([]byte, 112))) +} diff --git a/protocol/v2/types/gloas/constants.go b/protocol/v2/types/gloas/constants.go new file mode 100644 index 0000000000..0ba0262eab --- /dev/null +++ b/protocol/v2/types/gloas/constants.go @@ -0,0 +1,53 @@ +// Package gloas holds the node-side protocol/wire types and constants introduced by +// ePBS (EIP-7732 / Gloas), per SIP ssvlabs/SIPs#94. They are defined node-side as +// values of the existing spectypes base types, slotting above the consolidated Boole +// roles (max RoleAggregatorCommittee = 6). +// +// The wire values here are protocol-canonical and MUST match the Anchor (Rust) client; +// do not change them without a coordinated cross-client + SIP update. +package gloas + +import ( + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +// Runner roles for the three new ePBS duties. The deprecated RunnerRole 1/3 +// (RoleAggregator / RoleSyncCommitteeContribution) stay reserved for pre-consolidation +// back-compat decoding — see protocol/v2/types/runner_role.go. +const ( + RolePTC = spectypes.RunnerRole(7) // §3 payload-attestation (PTC) attester + RoleProposerPreferences = spectypes.RunnerRole(8) // §5 proposer preferences + RoleEnvelope = spectypes.RunnerRole(9) // §6 execution-payload envelope +) + +// Beacon (duty) roles mirroring the runner roles above. Existing BeaconRole values +// run 0..6 (BNRoleVoluntaryExit = 6), so 7/8/9 are the next free slots. +const ( + BNRolePTC = spectypes.BeaconRole(7) + BNRoleProposerPreferences = spectypes.BeaconRole(8) + BNRoleEnvelope = spectypes.BeaconRole(9) +) + +// Partial-signature message types. Existing values run up to +// AggregatorCommitteePartialSig = 6. +const ( + // PTCAttesterPartialSig is the partial signature over PayloadAttestationData (§3). + PTCAttesterPartialSig = spectypes.PartialSigMsgType(7) + // ProposerPreferencesPartialSig is the partial signature over ProposerPreferences (§5). + ProposerPreferencesPartialSig = spectypes.PartialSigMsgType(8) + // EnvelopePartialSig is reserved for the §6 envelope post-consensus, used only if the + // no-QBFT (sign-all) variant is chosen; the SIP-default QBFT variant reuses + // PostConsensusPartialSig (role discriminates routing), leaving this unused otherwise. + EnvelopePartialSig = spectypes.PartialSigMsgType(9) +) + +// Beacon signing domains introduced by Gloas — consensus-spec domains (4 bytes, +// domain number in byte[0], matching the spectypes.Domain* style). +var ( + // DomainBeaconBuilder signs the (blinded) ExecutionPayloadEnvelope (§6). + DomainBeaconBuilder = [4]byte{0x0b, 0x00, 0x00, 0x00} + // DomainPTCAttester signs PayloadAttestationData (§3). + DomainPTCAttester = [4]byte{0x0c, 0x00, 0x00, 0x00} + // DomainProposerPreferences signs ProposerPreferences (§5). + DomainProposerPreferences = [4]byte{0x0d, 0x00, 0x00, 0x00} +) diff --git a/protocol/v2/types/gloas/constants_test.go b/protocol/v2/types/gloas/constants_test.go new file mode 100644 index 0000000000..d70847722d --- /dev/null +++ b/protocol/v2/types/gloas/constants_test.go @@ -0,0 +1,28 @@ +package gloas + +import ( + "testing" + + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" +) + +// TestWireConstants pins the SIP-canonical wire values. These MUST match the Anchor +// client byte-for-byte — a change here is a cross-client wire break, not a refactor. +func TestWireConstants(t *testing.T) { + require.Equal(t, spectypes.RunnerRole(7), RolePTC) + require.Equal(t, spectypes.RunnerRole(8), RoleProposerPreferences) + require.Equal(t, spectypes.RunnerRole(9), RoleEnvelope) + + require.Equal(t, spectypes.BeaconRole(7), BNRolePTC) + require.Equal(t, spectypes.BeaconRole(8), BNRoleProposerPreferences) + require.Equal(t, spectypes.BeaconRole(9), BNRoleEnvelope) + + require.Equal(t, spectypes.PartialSigMsgType(7), PTCAttesterPartialSig) + require.Equal(t, spectypes.PartialSigMsgType(8), ProposerPreferencesPartialSig) + require.Equal(t, spectypes.PartialSigMsgType(9), EnvelopePartialSig) + + require.Equal(t, [4]byte{0x0b, 0x00, 0x00, 0x00}, DomainBeaconBuilder) + require.Equal(t, [4]byte{0x0c, 0x00, 0x00, 0x00}, DomainPTCAttester) + require.Equal(t, [4]byte{0x0d, 0x00, 0x00, 0x00}, DomainProposerPreferences) +} diff --git a/protocol/v2/types/gloas/fork.go b/protocol/v2/types/gloas/fork.go new file mode 100644 index 0000000000..4bf64aed70 --- /dev/null +++ b/protocol/v2/types/gloas/fork.go @@ -0,0 +1,17 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec" +) + +// DataVersionGloas is a node-side placeholder for the Gloas beacon data version: until +// go-eth2-client defines it (its latest is Fulu), we slot Gloas immediately after Fulu. +// Remove and reconcile with upstream once it ships a real spec.DataVersionGloas. Note +// DataVersionGloas.String() returns "unknown" — the spec string/JSON tables aren't extended. +const DataVersionGloas = spec.DataVersionFulu + 1 + +// IsGloas reports whether the given beacon data version is Gloas (ePBS). For epoch-level +// fork gating use networkconfig (*Beacon).IsGloas instead. +func IsGloas(v spec.DataVersion) bool { + return v == DataVersionGloas +} diff --git a/protocol/v2/types/gloas/fork_test.go b/protocol/v2/types/gloas/fork_test.go new file mode 100644 index 0000000000..9b36f9a856 --- /dev/null +++ b/protocol/v2/types/gloas/fork_test.go @@ -0,0 +1,18 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestDataVersionGloas_Placeholder(t *testing.T) { + // Gloas slots immediately after the current upstream max (Fulu = 7). If this fails, + // go-eth2-client's DataVersion enum shifted — reconcile the placeholder. + require.Equal(t, spec.DataVersion(8), DataVersionGloas) + + require.True(t, IsGloas(DataVersionGloas)) + require.False(t, IsGloas(spec.DataVersionFulu)) + require.False(t, IsGloas(spec.DataVersionElectra)) +} From c8a7fb6e4f889a6c9c6a9fc8f61587b1ddf21df3 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 13:53:34 +0300 Subject: [PATCH 003/150] beacon: wire GLOAS_FORK_EPOCH from BN spec and add (*Beacon).IsGloas Spec() reads GLOAS_FORK_EPOCH as non-required (far-future when the BN does not expose it) into the fork map. (*Beacon).IsGloas(epoch) gates on it and returns false on pre-Gloas networks or when the entry is absent. BeaconForkAtEpoch is unchanged. --- beacon/goclient/spec.go | 19 ++++++++++++++++- networkconfig/beacon.go | 10 +++++++++ networkconfig/beacon_gloas_test.go | 33 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 networkconfig/beacon_gloas_test.go diff --git a/beacon/goclient/spec.go b/beacon/goclient/spec.go index 0f26b32507..634a6db8d9 100644 --- a/beacon/goclient/spec.go +++ b/beacon/goclient/spec.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) const ( @@ -252,7 +253,10 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi return nil, err } - // TODO: Add GLOAS_FORK_EPOCH as non-required once fork specs are available + gloasEpoch, err := getForkEpoch("GLOAS_FORK_EPOCH", false) + if err != nil { + return nil, err + } // Only get fork version if the fork is scheduled (not FarFutureEpoch) var fuluForkVersion phase0.Version @@ -263,6 +267,14 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi } } + var gloasForkVersion phase0.Version + if gloasEpoch != FarFutureEpoch { + gloasForkVersion, err = getForkVersion("GLOAS_FORK_VERSION") + if err != nil { + return nil, err + } + } + forkEpochs := map[spec.DataVersion]phase0.Fork{ spec.DataVersionPhase0: { PreviousVersion: genesisForkVersion, @@ -299,6 +311,11 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi CurrentVersion: fuluForkVersion, Epoch: fuluEpoch, }, + gloas.DataVersionGloas: { + PreviousVersion: fuluForkVersion, + CurrentVersion: gloasForkVersion, + Epoch: gloasEpoch, + }, } return forkEpochs, nil diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index 230f27e8ad..0fa6700ec6 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -9,6 +9,8 @@ import ( "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) // Beacon defines beacon network configuration. It is fetched from the consensus client during the node runtime. @@ -173,6 +175,14 @@ func (b *Beacon) ForkAtVersion(version spec.DataVersion) (phase0.Fork, bool) { return fork, ok } +// IsGloas reports whether the beacon fork active at the given epoch is Gloas (ePBS). +// Returns false when there is no scheduled Gloas fork (absent from Forks or far-future), +// so it is safe on pre-Gloas networks and Beacon values without a Gloas entry. +func (b *Beacon) IsGloas(epoch phase0.Epoch) bool { + fork, ok := b.Forks[gloas.DataVersionGloas] + return ok && epoch >= fork.Epoch +} + func (b *Beacon) AssertSame(other *Beacon) error { if b.Name != other.Name { return fmt.Errorf("different Name") diff --git a/networkconfig/beacon_gloas_test.go b/networkconfig/beacon_gloas_test.go new file mode 100644 index 0000000000..079f2c2ade --- /dev/null +++ b/networkconfig/beacon_gloas_test.go @@ -0,0 +1,33 @@ +package networkconfig + +import ( + "math" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +func TestBeacon_IsGloas(t *testing.T) { + // No Gloas entry in the fork map → never Gloas. + none := &Beacon{Forks: map[spec.DataVersion]phase0.Fork{}} + require.False(t, none.IsGloas(0)) + require.False(t, none.IsGloas(1_000_000)) + + // Unscheduled Gloas (far-future sentinel) → never Gloas. + farFuture := &Beacon{Forks: map[spec.DataVersion]phase0.Fork{ + gloas.DataVersionGloas: {Epoch: phase0.Epoch(math.MaxUint64)}, + }} + require.False(t, farFuture.IsGloas(1_000_000)) + + // Scheduled at epoch 100. + scheduled := &Beacon{Forks: map[spec.DataVersion]phase0.Fork{ + gloas.DataVersionGloas: {Epoch: 100}, + }} + require.False(t, scheduled.IsGloas(99)) + require.True(t, scheduled.IsGloas(100)) + require.True(t, scheduled.IsGloas(101)) +} From 806c7982e286e1c9e544803133aadac4a5ba8c72 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 20:37:52 +0300 Subject: [PATCH 004/150] gloas: add PTC payload-attestation types and goclient endpoints Node-side surface for Gloas (ePBS) Payload Timeliness Committee duties, per SIP ssvlabs/SIPs#94: - types/gloas: PayloadAttestationData (42B SSZ) and PayloadAttestationMessage (146B SSZ), plus JSON-only PTCDuty, matching the beacon-APIs Gloas schemas; SSZ round-trip and golden JSON wire-format tests. - goclient: hand-rolled HTTP for the three PTC endpoints (get duties, produce data, submit messages). go-eth2-client has no Gloas provider yet, so these are direct requests until it is rebased. - beacon: standalone PTCCalls interface (folded into BeaconNode with the PTC runner later) plus regenerated MockPTCCalls; GoClient conformance asserted in tests. --- beacon/goclient/ptc.go | 170 ++++++++++++++++ beacon/goclient/ptc_test.go | 98 ++++++++++ protocol/v2/blockchain/beacon/client.go | 14 ++ protocol/v2/blockchain/beacon/mock_client.go | 69 +++++++ .../v2/types/gloas/payload_attestation.go | 117 +++++++++++ .../gloas/payload_attestation_encoding.go | 181 ++++++++++++++++++ .../types/gloas/payload_attestation_test.go | 98 ++++++++++ protocol/v2/types/gloas/ptc_duty.go | 64 +++++++ protocol/v2/types/gloas/ptc_duty_test.go | 26 +++ 9 files changed, 837 insertions(+) create mode 100644 beacon/goclient/ptc.go create mode 100644 beacon/goclient/ptc_test.go create mode 100644 protocol/v2/types/gloas/payload_attestation.go create mode 100644 protocol/v2/types/gloas/payload_attestation_encoding.go create mode 100644 protocol/v2/types/gloas/payload_attestation_test.go create mode 100644 protocol/v2/types/gloas/ptc_duty.go create mode 100644 protocol/v2/types/gloas/ptc_duty_test.go diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go new file mode 100644 index 0000000000..6c1b6c563e --- /dev/null +++ b/beacon/goclient/ptc.go @@ -0,0 +1,170 @@ +package goclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// Gloas (ePBS) Payload Timeliness Committee endpoints. go-eth2-client has no Gloas provider +// yet, so these are issued as hand-rolled HTTP requests until it is rebased onto a Gloas-aware +// release, at which point they become typed provider calls like the rest of GoClient. +const ( + ptcDutiesPath = "/eth/v1/validator/duties/ptc/%d" // epoch + payloadAttestationDataPath = "/eth/v1/validator/payload_attestation_data/%d" // slot + payloadAttestationsPath = "/eth/v1/beacon/pool/payload_attestations" + + // consensusVersionGloas is the Eth-Consensus-Version header value for Gloas payload attestations. + consensusVersionGloas = "gloas" +) + +// ptcHTTPClient issues the hand-rolled PTC requests; per-call deadlines come from the request +// context. It carries no operator transport config (TLS/auth) — acceptable for this interim +// surface, to be retired with the go-eth2-client rebase. +var ptcHTTPClient = &http.Client{} + +// PayloadAttestationDuties returns the PTC duties for the given validators at the epoch, from +// the first beacon client that responds. +func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + reqCtx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + var errs error + for _, client := range gc.clients { + start := time.Now() + duties, err := requestPTCDuties(reqCtx, ptcHTTPClient, client.Address(), epoch, validatorIndices) + recordRequest(reqCtx, gc.log, "PayloadAttestationDuties", client, http.MethodPost, false, time.Since(start), err) + if err != nil { + errs = errors.Join(errs, errSingleClient(err, client.Address(), "PayloadAttestationDuties")) + continue + } + return duties, nil + } + return nil, errs +} + +// PayloadAttestationData returns the PayloadAttestationData to attest to for the slot, from the +// first beacon client that responds. +func (gc *GoClient) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + reqCtx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + var errs error + for _, client := range gc.clients { + start := time.Now() + data, err := requestPayloadAttestationData(reqCtx, ptcHTTPClient, client.Address(), slot) + recordRequest(reqCtx, gc.log, "PayloadAttestationData", client, http.MethodGet, false, time.Since(start), err) + if err != nil { + errs = errors.Join(errs, errSingleClient(err, client.Address(), "PayloadAttestationData")) + continue + } + return data, nil + } + return nil, errs +} + +// SubmitPayloadAttestationMessages broadcasts signed PTC messages to every beacon client's pool, +// succeeding if at least one accepts them. +func (gc *GoClient) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + return gc.multiClientSubmit(ctx, "SubmitPayloadAttestationMessages", func(ctx context.Context, client Client) error { + return submitPayloadAttestationMessages(ctx, ptcHTTPClient, client.Address(), messages) + }) +} + +// requestPTCDuties POSTs the validator indices and returns their PTC duties for the epoch. +func requestPTCDuties(ctx context.Context, httpClient *http.Client, addr string, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + indices := make([]string, len(validatorIndices)) + for i, idx := range validatorIndices { + indices[i] = strconv.FormatUint(uint64(idx), 10) + } + body, err := json.Marshal(indices) + if err != nil { + return nil, fmt.Errorf("marshal validator indices: %w", err) + } + + var resp struct { + Data []*gloas.PTCDuty `json:"data"` + } + if err := ptcDo(ctx, httpClient, http.MethodPost, addr+fmt.Sprintf(ptcDutiesPath, epoch), body, nil, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// requestPayloadAttestationData GETs the PayloadAttestationData the PTC member must attest to for the slot. +func requestPayloadAttestationData(ctx context.Context, httpClient *http.Client, addr string, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + var resp struct { + Data *gloas.PayloadAttestationData `json:"data"` + } + if err := ptcDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(payloadAttestationDataPath, slot), nil, nil, &resp); err != nil { + return nil, err + } + if resp.Data == nil { + return nil, errors.New("no payload attestation data in response") + } + return resp.Data, nil +} + +// submitPayloadAttestationMessages POSTs signed PTC messages to the beacon node's pool. +func submitPayloadAttestationMessages(ctx context.Context, httpClient *http.Client, addr string, messages []*gloas.PayloadAttestationMessage) error { + body, err := json.Marshal(messages) + if err != nil { + return fmt.Errorf("marshal payload attestation messages: %w", err) + } + headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + return ptcDo(ctx, httpClient, http.MethodPost, addr+payloadAttestationsPath, body, headers, nil) +} + +// ptcDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil +// to ignore the body). A nil body sends no request payload; extraHeaders are applied last. +func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, url, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBody))) + } + if out != nil { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/beacon/goclient/ptc_test.go b/beacon/goclient/ptc_test.go new file mode 100644 index 0000000000..bc96bc584c --- /dev/null +++ b/beacon/goclient/ptc_test.go @@ -0,0 +1,98 @@ +package goclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// GoClient must satisfy the PTC beacon-node surface. +var _ beacon.PTCCalls = (*GoClient)(nil) + +func TestRequestPTCDuties(t *testing.T) { + duty := &gloas.PTCDuty{PubKey: phase0.BLSPubKey{0x11, 0x22}, ValidatorIndex: 7, Slot: 9} + dutyJSON, err := json.Marshal(duty) + require.NoError(t, err) + + var gotMethod, gotPath string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + _, _ = fmt.Fprintf(w, `{"dependent_root":"0x00","execution_optimistic":false,"data":[%s]}`, dutyJSON) + })) + defer srv.Close() + + duties, err := requestPTCDuties(context.Background(), srv.Client(), srv.URL, 3, []phase0.ValidatorIndex{7, 8}) + require.NoError(t, err) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v1/validator/duties/ptc/3", gotPath) + require.JSONEq(t, `["7","8"]`, string(gotBody)) + require.Equal(t, []*gloas.PTCDuty{duty}, duties) +} + +func TestRequestPayloadAttestationData(t *testing.T) { + data := &gloas.PayloadAttestationData{BeaconBlockRoot: phase0.Root{0xaa}, Slot: 9, PayloadPresent: true} + dataJSON, err := json.Marshal(data) + require.NoError(t, err) + + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + _, _ = fmt.Fprintf(w, `{"version":"gloas","data":%s}`, dataJSON) + })) + defer srv.Close() + + got, err := requestPayloadAttestationData(context.Background(), srv.Client(), srv.URL, 9) + require.NoError(t, err) + require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, "/eth/v1/validator/payload_attestation_data/9", gotPath) + require.Equal(t, data, got) +} + +func TestSubmitPayloadAttestationMessages(t *testing.T) { + msgs := []*gloas.PayloadAttestationMessage{{ + ValidatorIndex: 7, + Data: &gloas.PayloadAttestationData{BeaconBlockRoot: phase0.Root{0xaa}, Slot: 9, PayloadPresent: true}, + Signature: phase0.BLSSignature{0xbb}, + }} + + var gotMethod, gotPath, gotVersion string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotVersion = r.Header.Get("Eth-Consensus-Version") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + require.NoError(t, submitPayloadAttestationMessages(context.Background(), srv.Client(), srv.URL, msgs)) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v1/beacon/pool/payload_attestations", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + want, err := json.Marshal(msgs) + require.NoError(t, err) + require.JSONEq(t, string(want), string(gotBody)) +} + +func TestPTCDo_ErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":503,"message":"beacon node is syncing"}`, http.StatusServiceUnavailable) + })) + defer srv.Close() + + _, err := requestPayloadAttestationData(context.Background(), srv.Client(), srv.URL, 5) + require.Error(t, err) + require.Contains(t, err.Error(), "503") +} diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 69bb40538a..ae99328150 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -9,6 +9,8 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) //go:generate go tool -modfile=../../../../tool.mod mockgen -package=beacon -destination=./mock_client.go -source=./client.go @@ -133,3 +135,15 @@ type BeaconNode interface { signer // TODO need to handle differently proposalPreparations } + +// PTCCalls is the beacon-node surface for Gloas (ePBS) Payload Timeliness Committee duties: +// fetching assignments, producing the data to attest to, and submitting signed messages. It is +// declared standalone and folded into BeaconNode once the PTC runner is wired in. +type PTCCalls interface { + // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch. + PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) + // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot. + PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) + // SubmitPayloadAttestationMessages submits signed PTC messages to the beacon node's pool. + SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error +} diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index ed4399d087..503c8cce22 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -19,6 +19,7 @@ import ( altair "github.com/attestantio/go-eth2-client/spec/altair" phase0 "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" + gloas "github.com/ssvlabs/ssv/protocol/v2/types/gloas" gomock "go.uber.org/mock/gomock" ) @@ -1113,3 +1114,71 @@ func (mr *MockBeaconNodeMockRecorder) SyncCommitteeSubnetID(index any) *gomock.C mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncCommitteeSubnetID", reflect.TypeOf((*MockBeaconNode)(nil).SyncCommitteeSubnetID), index) } + +// MockPTCCalls is a mock of PTCCalls interface. +type MockPTCCalls struct { + ctrl *gomock.Controller + recorder *MockPTCCallsMockRecorder + isgomock struct{} +} + +// MockPTCCallsMockRecorder is the mock recorder for MockPTCCalls. +type MockPTCCallsMockRecorder struct { + mock *MockPTCCalls +} + +// NewMockPTCCalls creates a new mock instance. +func NewMockPTCCalls(ctrl *gomock.Controller) *MockPTCCalls { + mock := &MockPTCCalls{ctrl: ctrl} + mock.recorder = &MockPTCCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPTCCalls) EXPECT() *MockPTCCallsMockRecorder { + return m.recorder +} + +// PayloadAttestationData mocks base method. +func (m *MockPTCCalls) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationData", ctx, slot) + ret0, _ := ret[0].(*gloas.PayloadAttestationData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationData indicates an expected call of PayloadAttestationData. +func (mr *MockPTCCallsMockRecorder) PayloadAttestationData(ctx, slot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationData", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationData), ctx, slot) +} + +// PayloadAttestationDuties mocks base method. +func (m *MockPTCCalls) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationDuties", ctx, epoch, validatorIndices) + ret0, _ := ret[0].([]*gloas.PTCDuty) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationDuties indicates an expected call of PayloadAttestationDuties. +func (mr *MockPTCCallsMockRecorder) PayloadAttestationDuties(ctx, epoch, validatorIndices any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationDuties", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationDuties), ctx, epoch, validatorIndices) +} + +// SubmitPayloadAttestationMessages mocks base method. +func (m *MockPTCCalls) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitPayloadAttestationMessages", ctx, messages) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitPayloadAttestationMessages indicates an expected call of SubmitPayloadAttestationMessages. +func (mr *MockPTCCallsMockRecorder) SubmitPayloadAttestationMessages(ctx, messages any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitPayloadAttestationMessages", reflect.TypeOf((*MockPTCCalls)(nil).SubmitPayloadAttestationMessages), ctx, messages) +} diff --git a/protocol/v2/types/gloas/payload_attestation.go b/protocol/v2/types/gloas/payload_attestation.go new file mode 100644 index 0000000000..381fc8ca31 --- /dev/null +++ b/protocol/v2/types/gloas/payload_attestation.go @@ -0,0 +1,117 @@ +package gloas + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./payload_attestation.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs PayloadAttestationData,PayloadAttestationMessage" + +// PayloadAttestationData is the Gloas (ePBS) datum a PTC member attests to: whether the +// execution payload for BeaconBlockRoot at Slot was present, and its blobs available. +// Signed under DomainPTCAttester. Fixed 42-byte SSZ. +type PayloadAttestationData struct { + BeaconBlockRoot phase0.Root `ssz-size:"32"` + Slot phase0.Slot + PayloadPresent bool + BlobDataAvailable bool +} + +// PayloadAttestationMessage is one PTC member's signed PayloadAttestationData, submitted +// to the beacon node's payload_attestations pool. Fixed 146-byte SSZ. +type PayloadAttestationMessage struct { + ValidatorIndex phase0.ValidatorIndex + Data *PayloadAttestationData + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// payloadAttestationDataJSON is the beacon-API JSON form: uint64 as a decimal string, +// roots as 0x-hex, per go-eth2-client conventions. +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 fmt.Errorf("invalid JSON: %w", err) + } + root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) + if err != nil { + return fmt.Errorf("invalid value for beacon block root: %w", err) + } + if len(root) != phase0.RootLength { + return errors.New("incorrect length for beacon block root") + } + copy(p.BeaconBlockRoot[:], root) + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for slot: %w", err) + } + p.Slot = phase0.Slot(slot) + p.PayloadPresent = data.PayloadPresent + p.BlobDataAvailable = data.BlobDataAvailable + return nil +} + +// payloadAttestationMessageJSON is the beacon-API JSON form of PayloadAttestationMessage. +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 fmt.Errorf("invalid JSON: %w", err) + } + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for validator index: %w", err) + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + if data.Data == nil { + return errors.New("data missing") + } + p.Data = data.Data + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return fmt.Errorf("invalid value for signature: %w", err) + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(p.Signature[:], signature) + return nil +} diff --git a/protocol/v2/types/gloas/payload_attestation_encoding.go b/protocol/v2/types/gloas/payload_attestation_encoding.go new file mode 100644 index 0000000000..947c4806b1 --- /dev/null +++ b/protocol/v2/types/gloas/payload_attestation_encoding.go @@ -0,0 +1,181 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 1b95ae641e75632a5fb0e30aed38d36275e732adcca842bf04dadaa2ee75fc69 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the PayloadAttestationData object +func (p *PayloadAttestationData) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(p) +} + +// MarshalSSZTo ssz marshals the PayloadAttestationData object to a target array +func (p *PayloadAttestationData) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'BeaconBlockRoot' + dst = append(dst, p.BeaconBlockRoot[:]...) + + // Field (1) 'Slot' + dst = ssz.MarshalUint64(dst, uint64(p.Slot)) + + // Field (2) 'PayloadPresent' + dst = ssz.MarshalBool(dst, p.PayloadPresent) + + // Field (3) 'BlobDataAvailable' + dst = ssz.MarshalBool(dst, p.BlobDataAvailable) + + return +} + +// UnmarshalSSZ ssz unmarshals the PayloadAttestationData object +func (p *PayloadAttestationData) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 42 { + return ssz.ErrSize + } + + // Field (0) 'BeaconBlockRoot' + copy(p.BeaconBlockRoot[:], buf[0:32]) + + // Field (1) 'Slot' + p.Slot = phase0.Slot(ssz.UnmarshallUint64(buf[32:40])) + + // Field (2) 'PayloadPresent' + p.PayloadPresent = ssz.UnmarshalBool(buf[40:41]) + + // Field (3) 'BlobDataAvailable' + p.BlobDataAvailable = ssz.UnmarshalBool(buf[41:42]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the PayloadAttestationData object +func (p *PayloadAttestationData) SizeSSZ() (size int) { + size = 42 + return +} + +// HashTreeRoot ssz hashes the PayloadAttestationData object +func (p *PayloadAttestationData) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(p) +} + +// HashTreeRootWith ssz hashes the PayloadAttestationData object with a hasher +func (p *PayloadAttestationData) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'BeaconBlockRoot' + hh.PutBytes(p.BeaconBlockRoot[:]) + + // Field (1) 'Slot' + hh.PutUint64(uint64(p.Slot)) + + // Field (2) 'PayloadPresent' + hh.PutBool(p.PayloadPresent) + + // Field (3) 'BlobDataAvailable' + hh.PutBool(p.BlobDataAvailable) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the PayloadAttestationData object +func (p *PayloadAttestationData) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(p) +} + +// MarshalSSZ ssz marshals the PayloadAttestationMessage object +func (p *PayloadAttestationMessage) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(p) +} + +// MarshalSSZTo ssz marshals the PayloadAttestationMessage object to a target array +func (p *PayloadAttestationMessage) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'ValidatorIndex' + dst = ssz.MarshalUint64(dst, uint64(p.ValidatorIndex)) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if dst, err = p.Data.MarshalSSZTo(dst); err != nil { + return + } + + // Field (2) 'Signature' + dst = append(dst, p.Signature[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the PayloadAttestationMessage object +func (p *PayloadAttestationMessage) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 146 { + return ssz.ErrSize + } + + // Field (0) 'ValidatorIndex' + p.ValidatorIndex = phase0.ValidatorIndex(ssz.UnmarshallUint64(buf[0:8])) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if err = p.Data.UnmarshalSSZ(buf[8:50]); err != nil { + return err + } + + // Field (2) 'Signature' + copy(p.Signature[:], buf[50:146]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the PayloadAttestationMessage object +func (p *PayloadAttestationMessage) SizeSSZ() (size int) { + size = 146 + return +} + +// HashTreeRoot ssz hashes the PayloadAttestationMessage object +func (p *PayloadAttestationMessage) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(p) +} + +// HashTreeRootWith ssz hashes the PayloadAttestationMessage object with a hasher +func (p *PayloadAttestationMessage) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'ValidatorIndex' + hh.PutUint64(uint64(p.ValidatorIndex)) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if err = p.Data.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'Signature' + hh.PutBytes(p.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the PayloadAttestationMessage object +func (p *PayloadAttestationMessage) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(p) +} diff --git a/protocol/v2/types/gloas/payload_attestation_test.go b/protocol/v2/types/gloas/payload_attestation_test.go new file mode 100644 index 0000000000..e1e1647659 --- /dev/null +++ b/protocol/v2/types/gloas/payload_attestation_test.go @@ -0,0 +1,98 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestPayloadAttestationData_SSZ(t *testing.T) { + d := &PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01, 0x02}, + Slot: 42, + PayloadPresent: true, + BlobDataAvailable: false, + } + require.Equal(t, 42, d.SizeSSZ()) + + b, err := d.MarshalSSZ() + require.NoError(t, err) + require.Len(t, b, 42) + + var dec PayloadAttestationData + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, d, &dec) + + htr1, err := d.HashTreeRoot() + require.NoError(t, err) + htr2, err := dec.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, htr1, htr2) +} + +func TestPayloadAttestationMessage_SSZ(t *testing.T) { + m := &PayloadAttestationMessage{ + ValidatorIndex: 7, + Data: &PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0xaa}, + Slot: 9, + PayloadPresent: true, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{0xbb, 0xcc}, + } + require.Equal(t, 146, m.SizeSSZ()) + + b, err := m.MarshalSSZ() + require.NoError(t, err) + require.Len(t, b, 146) + + var dec PayloadAttestationMessage + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, m, &dec) + + _, err = m.HashTreeRoot() + require.NoError(t, err) +} + +func TestPayloadAttestationData_JSON(t *testing.T) { + d := &PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01, 0x02}, + Slot: 42, + PayloadPresent: true, + BlobDataAvailable: false, + } + b, err := json.Marshal(d) + require.NoError(t, err) + // Lock the beacon-API wire form: snake_case keys, slot as a decimal string, root as 0x-hex. + require.JSONEq(t, `{"beacon_block_root":"0x0102000000000000000000000000000000000000000000000000000000000000","slot":"42","payload_present":true,"blob_data_available":false}`, string(b)) + + var dec PayloadAttestationData + require.NoError(t, json.Unmarshal(b, &dec)) + require.Equal(t, d, &dec) +} + +func TestPayloadAttestationMessage_JSON(t *testing.T) { + m := &PayloadAttestationMessage{ + ValidatorIndex: 7, + Data: &PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0xaa}, + Slot: 9, + PayloadPresent: true, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{0xbb, 0xcc}, + } + b, err := json.Marshal(m) + require.NoError(t, err) + dataJSON, err := json.Marshal(m.Data) + require.NoError(t, err) + require.JSONEq(t, fmt.Sprintf(`{"validator_index":"7","data":%s,"signature":"%#x"}`, dataJSON, m.Signature), string(b)) + + var dec PayloadAttestationMessage + require.NoError(t, json.Unmarshal(b, &dec)) + require.Equal(t, m, &dec) +} diff --git a/protocol/v2/types/gloas/ptc_duty.go b/protocol/v2/types/gloas/ptc_duty.go new file mode 100644 index 0000000000..a71f567694 --- /dev/null +++ b/protocol/v2/types/gloas/ptc_duty.go @@ -0,0 +1,64 @@ +package gloas + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PTCDuty is one validator's Payload Timeliness Committee assignment for a slot, as returned +// by the beacon node's /eth/v1/validator/duties/ptc/{epoch} endpoint. JSON-only: PTC duties +// are never SSZ-encoded over the wire. +type PTCDuty struct { + PubKey phase0.BLSPubKey + ValidatorIndex phase0.ValidatorIndex + Slot phase0.Slot +} + +// ptcDutyJSON is the beacon-API JSON form: pubkey as 0x-hex, uint64 as a decimal string. +type ptcDutyJSON struct { + PubKey string `json:"pubkey"` + ValidatorIndex string `json:"validator_index"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (d *PTCDuty) MarshalJSON() ([]byte, error) { + return json.Marshal(&ptcDutyJSON{ + PubKey: fmt.Sprintf("%#x", d.PubKey), + ValidatorIndex: fmt.Sprintf("%d", d.ValidatorIndex), + Slot: fmt.Sprintf("%d", d.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *PTCDuty) UnmarshalJSON(input []byte) error { + var data ptcDutyJSON + if err := json.Unmarshal(input, &data); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + pubKey, err := hex.DecodeString(strings.TrimPrefix(data.PubKey, "0x")) + if err != nil { + return fmt.Errorf("invalid value for pubkey: %w", err) + } + if len(pubKey) != phase0.PublicKeyLength { + return errors.New("incorrect length for pubkey") + } + copy(d.PubKey[:], pubKey) + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for validator index: %w", err) + } + d.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for slot: %w", err) + } + d.Slot = phase0.Slot(slot) + return nil +} diff --git a/protocol/v2/types/gloas/ptc_duty_test.go b/protocol/v2/types/gloas/ptc_duty_test.go new file mode 100644 index 0000000000..b6905c15a4 --- /dev/null +++ b/protocol/v2/types/gloas/ptc_duty_test.go @@ -0,0 +1,26 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestPTCDuty_JSON(t *testing.T) { + d := &PTCDuty{ + PubKey: phase0.BLSPubKey{0x11, 0x22}, + ValidatorIndex: 1234, + Slot: 567, + } + b, err := json.Marshal(d) + require.NoError(t, err) + // Lock the beacon-API wire form: pubkey as 0x-hex, validator_index and slot as decimal strings. + require.JSONEq(t, fmt.Sprintf(`{"pubkey":"%#x","validator_index":"1234","slot":"567"}`, d.PubKey), string(b)) + + var dec PTCDuty + require.NoError(t, json.Unmarshal(b, &dec)) + require.Equal(t, d, &dec) +} From e5f20617ed7a6c1ff9b3fcee326fbc477126b911 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 21:11:12 +0300 Subject: [PATCH 005/150] gloas: align ePBS role names with SIP #94 and map beacon->runner roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename to the SIP-canonical identifiers: RolePTCAttester / BNRolePTCAttester and RoleEnvelopeBuilder / BNRoleEnvelopeBuilder. Drop EnvelopePartialSig — the §6 envelope post-consensus reuses PostConsensusPartialSig, discriminated by role. - Add RunnerRoleForBeaconRole and wire it into RunnerRoleForValidatorDuty, since ssv-spec's ValidatorDuty.RunnerRole() predates these roles and must be resolved node-side. --- protocol/v2/types/gloas/constants.go | 31 ++++++++++++++++------- protocol/v2/types/gloas/constants_test.go | 29 +++++++++++++++++---- protocol/v2/types/runner_role.go | 6 +++++ protocol/v2/types/runner_role_test.go | 12 +++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/protocol/v2/types/gloas/constants.go b/protocol/v2/types/gloas/constants.go index 0ba0262eab..651354b405 100644 --- a/protocol/v2/types/gloas/constants.go +++ b/protocol/v2/types/gloas/constants.go @@ -15,30 +15,27 @@ import ( // (RoleAggregator / RoleSyncCommitteeContribution) stay reserved for pre-consolidation // back-compat decoding — see protocol/v2/types/runner_role.go. const ( - RolePTC = spectypes.RunnerRole(7) // §3 payload-attestation (PTC) attester + RolePTCAttester = spectypes.RunnerRole(7) // §3 payload-attestation (PTC) attester RoleProposerPreferences = spectypes.RunnerRole(8) // §5 proposer preferences - RoleEnvelope = spectypes.RunnerRole(9) // §6 execution-payload envelope + RoleEnvelopeBuilder = spectypes.RunnerRole(9) // §6 execution-payload envelope ) // Beacon (duty) roles mirroring the runner roles above. Existing BeaconRole values // run 0..6 (BNRoleVoluntaryExit = 6), so 7/8/9 are the next free slots. const ( - BNRolePTC = spectypes.BeaconRole(7) + BNRolePTCAttester = spectypes.BeaconRole(7) BNRoleProposerPreferences = spectypes.BeaconRole(8) - BNRoleEnvelope = spectypes.BeaconRole(9) + BNRoleEnvelopeBuilder = spectypes.BeaconRole(9) ) // Partial-signature message types. Existing values run up to -// AggregatorCommitteePartialSig = 6. +// AggregatorCommitteePartialSig = 6. The §6 envelope duty adds no type of its own: +// its post-consensus reuses PostConsensusPartialSig, discriminated by runner role. const ( // PTCAttesterPartialSig is the partial signature over PayloadAttestationData (§3). PTCAttesterPartialSig = spectypes.PartialSigMsgType(7) // ProposerPreferencesPartialSig is the partial signature over ProposerPreferences (§5). ProposerPreferencesPartialSig = spectypes.PartialSigMsgType(8) - // EnvelopePartialSig is reserved for the §6 envelope post-consensus, used only if the - // no-QBFT (sign-all) variant is chosen; the SIP-default QBFT variant reuses - // PostConsensusPartialSig (role discriminates routing), leaving this unused otherwise. - EnvelopePartialSig = spectypes.PartialSigMsgType(9) ) // Beacon signing domains introduced by Gloas — consensus-spec domains (4 bytes, @@ -51,3 +48,19 @@ var ( // DomainProposerPreferences signs ProposerPreferences (§5). DomainProposerPreferences = [4]byte{0x0d, 0x00, 0x00, 0x00} ) + +// RunnerRoleForBeaconRole maps a Gloas (ePBS) beacon duty role to its runner role, +// reporting ok=false for non-Gloas roles. ssv-spec's ValidatorDuty.RunnerRole() predates +// these roles, so callers apply this mapping node-side before delegating to it. +func RunnerRoleForBeaconRole(role spectypes.BeaconRole) (spectypes.RunnerRole, bool) { + switch role { + case BNRolePTCAttester: + return RolePTCAttester, true + case BNRoleProposerPreferences: + return RoleProposerPreferences, true + case BNRoleEnvelopeBuilder: + return RoleEnvelopeBuilder, true + default: + return spectypes.RoleUnknown, false + } +} diff --git a/protocol/v2/types/gloas/constants_test.go b/protocol/v2/types/gloas/constants_test.go index d70847722d..65d02b3b5c 100644 --- a/protocol/v2/types/gloas/constants_test.go +++ b/protocol/v2/types/gloas/constants_test.go @@ -10,19 +10,38 @@ import ( // TestWireConstants pins the SIP-canonical wire values. These MUST match the Anchor // client byte-for-byte — a change here is a cross-client wire break, not a refactor. func TestWireConstants(t *testing.T) { - require.Equal(t, spectypes.RunnerRole(7), RolePTC) + require.Equal(t, spectypes.RunnerRole(7), RolePTCAttester) require.Equal(t, spectypes.RunnerRole(8), RoleProposerPreferences) - require.Equal(t, spectypes.RunnerRole(9), RoleEnvelope) + require.Equal(t, spectypes.RunnerRole(9), RoleEnvelopeBuilder) - require.Equal(t, spectypes.BeaconRole(7), BNRolePTC) + require.Equal(t, spectypes.BeaconRole(7), BNRolePTCAttester) require.Equal(t, spectypes.BeaconRole(8), BNRoleProposerPreferences) - require.Equal(t, spectypes.BeaconRole(9), BNRoleEnvelope) + require.Equal(t, spectypes.BeaconRole(9), BNRoleEnvelopeBuilder) require.Equal(t, spectypes.PartialSigMsgType(7), PTCAttesterPartialSig) require.Equal(t, spectypes.PartialSigMsgType(8), ProposerPreferencesPartialSig) - require.Equal(t, spectypes.PartialSigMsgType(9), EnvelopePartialSig) require.Equal(t, [4]byte{0x0b, 0x00, 0x00, 0x00}, DomainBeaconBuilder) require.Equal(t, [4]byte{0x0c, 0x00, 0x00, 0x00}, DomainPTCAttester) require.Equal(t, [4]byte{0x0d, 0x00, 0x00, 0x00}, DomainProposerPreferences) } + +func TestRunnerRoleForBeaconRole(t *testing.T) { + for _, tc := range []struct { + name string + role spectypes.BeaconRole + want spectypes.RunnerRole + ok bool + }{ + {"ptc", BNRolePTCAttester, RolePTCAttester, true}, + {"proposer_preferences", BNRoleProposerPreferences, RoleProposerPreferences, true}, + {"envelope", BNRoleEnvelopeBuilder, RoleEnvelopeBuilder, true}, + {"non_gloas", spectypes.BNRoleAttester, spectypes.RoleUnknown, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := RunnerRoleForBeaconRole(tc.role) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/protocol/v2/types/runner_role.go b/protocol/v2/types/runner_role.go index d5ee5e41ea..e4b11507d8 100644 --- a/protocol/v2/types/runner_role.go +++ b/protocol/v2/types/runner_role.go @@ -2,6 +2,8 @@ package types import ( spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) const ( @@ -25,6 +27,10 @@ func RunnerRoleForValidatorDuty(duty *spectypes.ValidatorDuty, isBooleFork bool) if duty == nil { return spectypes.RoleUnknown } + // Gloas (ePBS) duties post-date ssv-spec's RunnerRole(); resolve them node-side first. + if role, ok := gloas.RunnerRoleForBeaconRole(duty.Type); ok { + return role + } if isBooleFork { return duty.RunnerRole() } diff --git a/protocol/v2/types/runner_role_test.go b/protocol/v2/types/runner_role_test.go index 228133d792..ea07568fb2 100644 --- a/protocol/v2/types/runner_role_test.go +++ b/protocol/v2/types/runner_role_test.go @@ -6,6 +6,8 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func TestCommitteeRunnerRoleForBeaconRole(t *testing.T) { @@ -74,3 +76,13 @@ func TestRunnerRoleForDuty_CommitteeDuty(t *testing.T) { require.True(t, ok) assert.Equal(t, spectypes.RoleAggregatorCommittee, role) } + +func TestRunnerRoleForValidatorDuty_Gloas(t *testing.T) { + duty := &spectypes.ValidatorDuty{Type: gloas.BNRolePTCAttester} + require.Equal(t, gloas.RolePTCAttester, RunnerRoleForValidatorDuty(duty, true)) + require.Equal(t, spectypes.RoleUnknown, RunnerRoleForValidatorDuty(nil, true)) + + // Non-Gloas roles still resolve via ssv-spec's RunnerRole(). + proposer := &spectypes.ValidatorDuty{Type: spectypes.BNRoleProposer} + require.Equal(t, spectypes.RoleProposer, RunnerRoleForValidatorDuty(proposer, true)) +} From b7b62363c76c2ae4c969caacbdc77ffa40a1a8c8 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 21:13:41 +0300 Subject: [PATCH 006/150] beacon: expose PTCCalls on the BeaconNode interface Embed the PTC call-set into BeaconNode so runners reach it through the same handle as every other duty, and regenerate MockBeaconNode. GoClient already implements it; BeaconNodeWrapped inherits it via its embedded BeaconNode. --- protocol/v2/blockchain/beacon/client.go | 4 +- protocol/v2/blockchain/beacon/mock_client.go | 44 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index ae99328150..cd6a0fcf17 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -127,6 +127,7 @@ type BeaconNode interface { SyncCommitteeContributionCalls ValidatorRegistrationCalls VoluntaryExitCalls + PTCCalls DomainCalls beaconDuties @@ -137,8 +138,7 @@ type BeaconNode interface { } // PTCCalls is the beacon-node surface for Gloas (ePBS) Payload Timeliness Committee duties: -// fetching assignments, producing the data to attest to, and submitting signed messages. It is -// declared standalone and folded into BeaconNode once the PTC runner is wired in. +// fetching assignments, producing the data to attest to, and submitting signed messages. type PTCCalls interface { // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch. PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index 503c8cce22..18c51ec43e 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -889,6 +889,36 @@ func (mr *MockBeaconNodeMockRecorder) IsSyncCommitteeAggregator(proof any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSyncCommitteeAggregator", reflect.TypeOf((*MockBeaconNode)(nil).IsSyncCommitteeAggregator), proof) } +// PayloadAttestationData mocks base method. +func (m *MockBeaconNode) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationData", ctx, slot) + ret0, _ := ret[0].(*gloas.PayloadAttestationData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationData indicates an expected call of PayloadAttestationData. +func (mr *MockBeaconNodeMockRecorder) PayloadAttestationData(ctx, slot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationData", reflect.TypeOf((*MockBeaconNode)(nil).PayloadAttestationData), ctx, slot) +} + +// PayloadAttestationDuties mocks base method. +func (m *MockBeaconNode) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationDuties", ctx, epoch, validatorIndices) + ret0, _ := ret[0].([]*gloas.PTCDuty) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationDuties indicates an expected call of PayloadAttestationDuties. +func (mr *MockBeaconNodeMockRecorder) PayloadAttestationDuties(ctx, epoch, validatorIndices any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationDuties", reflect.TypeOf((*MockBeaconNode)(nil).PayloadAttestationDuties), ctx, epoch, validatorIndices) +} + // ProposerDuties mocks base method. func (m *MockBeaconNode) ProposerDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*v1.ProposerDuty, error) { m.ctrl.T.Helper() @@ -974,6 +1004,20 @@ func (mr *MockBeaconNodeMockRecorder) SubmitBeaconCommitteeSubscriptions(ctx, su return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBeaconCommitteeSubscriptions", reflect.TypeOf((*MockBeaconNode)(nil).SubmitBeaconCommitteeSubscriptions), ctx, subscription) } +// SubmitPayloadAttestationMessages mocks base method. +func (m *MockBeaconNode) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitPayloadAttestationMessages", ctx, messages) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitPayloadAttestationMessages indicates an expected call of SubmitPayloadAttestationMessages. +func (mr *MockBeaconNodeMockRecorder) SubmitPayloadAttestationMessages(ctx, messages any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitPayloadAttestationMessages", reflect.TypeOf((*MockBeaconNode)(nil).SubmitPayloadAttestationMessages), ctx, messages) +} + // SubmitProposalPreparations mocks base method. func (m *MockBeaconNode) SubmitProposalPreparations(ctx context.Context, preparations []*v1.ProposalPreparation) error { m.ctrl.T.Helper() From 29d31ee9922ba32b9c9d4efce7943fa87d10e613 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 23 Jun 2026 22:03:37 +0300 Subject: [PATCH 007/150] gloas, goclient: dedupe PTC JSON hex-decoding and client-fallback loops - Extract decodeHexInto for the repeated 0x-hex decode + length check shared by the PayloadAttestationData/Message and PTCDuty JSON unmarshalers. - Collapse the two identical PTC read-fallback loops into a generic firstClientResult helper. - Align the two sszgen //go:generate comments. --- beacon/goclient/ptc.go | 57 +++++++++---------- protocol/v2/types/gloas/beacon_vote.go | 5 +- protocol/v2/types/gloas/hex.go | 21 +++++++ .../v2/types/gloas/payload_attestation.go | 22 ++----- protocol/v2/types/gloas/ptc_duty.go | 12 +--- 5 files changed, 58 insertions(+), 59 deletions(-) create mode 100644 protocol/v2/types/gloas/hex.go diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 6c1b6c563e..2fdb9a7104 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -37,41 +37,17 @@ var ptcHTTPClient = &http.Client{} // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch, from // the first beacon client that responds. func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { - reqCtx, cancel := context.WithTimeout(ctx, gc.commonTimeout) - defer cancel() - - var errs error - for _, client := range gc.clients { - start := time.Now() - duties, err := requestPTCDuties(reqCtx, ptcHTTPClient, client.Address(), epoch, validatorIndices) - recordRequest(reqCtx, gc.log, "PayloadAttestationDuties", client, http.MethodPost, false, time.Since(start), err) - if err != nil { - errs = errors.Join(errs, errSingleClient(err, client.Address(), "PayloadAttestationDuties")) - continue - } - return duties, nil - } - return nil, errs + return firstClientResult(ctx, gc, "PayloadAttestationDuties", http.MethodPost, func(ctx context.Context, addr string) ([]*gloas.PTCDuty, error) { + return requestPTCDuties(ctx, ptcHTTPClient, addr, epoch, validatorIndices) + }) } // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot, from the // first beacon client that responds. func (gc *GoClient) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { - reqCtx, cancel := context.WithTimeout(ctx, gc.commonTimeout) - defer cancel() - - var errs error - for _, client := range gc.clients { - start := time.Now() - data, err := requestPayloadAttestationData(reqCtx, ptcHTTPClient, client.Address(), slot) - recordRequest(reqCtx, gc.log, "PayloadAttestationData", client, http.MethodGet, false, time.Since(start), err) - if err != nil { - errs = errors.Join(errs, errSingleClient(err, client.Address(), "PayloadAttestationData")) - continue - } - return data, nil - } - return nil, errs + return firstClientResult(ctx, gc, "PayloadAttestationData", http.MethodGet, func(ctx context.Context, addr string) (*gloas.PayloadAttestationData, error) { + return requestPayloadAttestationData(ctx, ptcHTTPClient, addr, slot) + }) } // SubmitPayloadAttestationMessages broadcasts signed PTC messages to every beacon client's pool, @@ -85,6 +61,27 @@ func (gc *GoClient) SubmitPayloadAttestationMessages(ctx context.Context, messag }) } +// firstClientResult runs fn against each beacon client in turn under the common timeout, +// returning the first success and recording every attempt; on all failures it joins the errors. +func firstClientResult[T any](ctx context.Context, gc *GoClient, routeName, httpMethod string, fn func(ctx context.Context, addr string) (T, error)) (T, error) { + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + var zero T + var errs error + for _, client := range gc.clients { + start := time.Now() + res, err := fn(ctx, client.Address()) + recordRequest(ctx, gc.log, routeName, client, httpMethod, false, time.Since(start), err) + if err != nil { + errs = errors.Join(errs, errSingleClient(err, client.Address(), routeName)) + continue + } + return res, nil + } + return zero, errs +} + // requestPTCDuties POSTs the validator indices and returns their PTC duties for the epoch. func requestPTCDuties(ctx context.Context, httpClient *http.Client, addr string, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { indices := make([]string, len(validatorIndices)) diff --git a/protocol/v2/types/gloas/beacon_vote.go b/protocol/v2/types/gloas/beacon_vote.go index 084001f5ab..8ef11af848 100644 --- a/protocol/v2/types/gloas/beacon_vote.go +++ b/protocol/v2/types/gloas/beacon_vote.go @@ -4,9 +4,8 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ) -// The phase0 --include is resolved from the module graph (via `go list -m`), so it -// is not pinned to a go-eth2-client version and survives dependency bumps. -// Regenerate with `go generate ./...`. +// Regenerate with `go generate ./...`. The phase0 --include is resolved from the module +// graph (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. //go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./beacon_vote.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs GloasBeaconVote" // GloasBeaconVote is the Gloas (ePBS) variant of spectypes.BeaconVote — the value the diff --git a/protocol/v2/types/gloas/hex.go b/protocol/v2/types/gloas/hex.go new file mode 100644 index 0000000000..05eda85cee --- /dev/null +++ b/protocol/v2/types/gloas/hex.go @@ -0,0 +1,21 @@ +package gloas + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// decodeHexInto decodes a 0x-prefixed hex string into dst, requiring exactly len(dst) bytes. +// field names the value in error messages. +func decodeHexInto(dst []byte, s, field string) error { + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil { + return fmt.Errorf("invalid value for %s: %w", field, err) + } + if len(b) != len(dst) { + return fmt.Errorf("incorrect length for %s", field) + } + copy(dst, b) + return nil +} diff --git a/protocol/v2/types/gloas/payload_attestation.go b/protocol/v2/types/gloas/payload_attestation.go index 381fc8ca31..8af5745edf 100644 --- a/protocol/v2/types/gloas/payload_attestation.go +++ b/protocol/v2/types/gloas/payload_attestation.go @@ -1,16 +1,16 @@ package gloas import ( - "encoding/hex" "encoding/json" "errors" "fmt" "strconv" - "strings" "github.com/attestantio/go-eth2-client/spec/phase0" ) +// Regenerate with `go generate ./...`. The phase0 --include is resolved from the module +// graph (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. //go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./payload_attestation.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs PayloadAttestationData,PayloadAttestationMessage" // PayloadAttestationData is the Gloas (ePBS) datum a PTC member attests to: whether the @@ -56,14 +56,9 @@ func (p *PayloadAttestationData) UnmarshalJSON(input []byte) error { if err := json.Unmarshal(input, &data); err != nil { return fmt.Errorf("invalid JSON: %w", err) } - root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) - if err != nil { - return fmt.Errorf("invalid value for beacon block root: %w", err) - } - if len(root) != phase0.RootLength { - return errors.New("incorrect length for beacon block root") + if err := decodeHexInto(p.BeaconBlockRoot[:], data.BeaconBlockRoot, "beacon block root"); err != nil { + return err } - copy(p.BeaconBlockRoot[:], root) slot, err := strconv.ParseUint(data.Slot, 10, 64) if err != nil { return fmt.Errorf("invalid value for slot: %w", err) @@ -105,13 +100,8 @@ func (p *PayloadAttestationMessage) UnmarshalJSON(input []byte) error { return errors.New("data missing") } p.Data = data.Data - signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) - if err != nil { - return fmt.Errorf("invalid value for signature: %w", err) - } - if len(signature) != phase0.SignatureLength { - return errors.New("incorrect length for signature") + if err := decodeHexInto(p.Signature[:], data.Signature, "signature"); err != nil { + return err } - copy(p.Signature[:], signature) return nil } diff --git a/protocol/v2/types/gloas/ptc_duty.go b/protocol/v2/types/gloas/ptc_duty.go index a71f567694..6cf5f450dd 100644 --- a/protocol/v2/types/gloas/ptc_duty.go +++ b/protocol/v2/types/gloas/ptc_duty.go @@ -1,12 +1,9 @@ package gloas import ( - "encoding/hex" "encoding/json" - "errors" "fmt" "strconv" - "strings" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -42,14 +39,9 @@ func (d *PTCDuty) UnmarshalJSON(input []byte) error { if err := json.Unmarshal(input, &data); err != nil { return fmt.Errorf("invalid JSON: %w", err) } - pubKey, err := hex.DecodeString(strings.TrimPrefix(data.PubKey, "0x")) - if err != nil { - return fmt.Errorf("invalid value for pubkey: %w", err) - } - if len(pubKey) != phase0.PublicKeyLength { - return errors.New("incorrect length for pubkey") + if err := decodeHexInto(d.PubKey[:], data.PubKey, "pubkey"); err != nil { + return err } - copy(d.PubKey[:], pubKey) validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) if err != nil { return fmt.Errorf("invalid value for validator index: %w", err) From 52484bf4e6e6ea61602c62c20dc125f6c36322c2 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 10:37:12 +0300 Subject: [PATCH 008/150] ekm: support Gloas (ePBS) PTC payload-attestation signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump ssv-spec to the branch carrying the ePBS constants (ssvlabs/ssv-spec#632) and add DomainPTCAttester signing: - LocalKeyManager signs the payload-attestation root via the generic ssz.HashRoot signer (no slashing protection — PTC is not in the slashing predicate). - RemoteKeyManager returns an explicit "not supported" error, since Web3Signer has no payload-attestation request type. Also realign a test for the ssv-spec testingutils change (TestingPhase0AggregateAndProof is now a constructor). --- go.mod | 4 ++-- go.sum | 2 ++ ssvsigner/ekm/local_key_manager.go | 5 +++++ ssvsigner/ekm/remote_key_manager.go | 4 ++++ ssvsigner/go.mod | 2 +- ssvsigner/go.sum | 4 ++-- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 07df3a665b..f54461ccdb 100644 --- a/go.mod +++ b/go.mod @@ -40,8 +40,8 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.8.1 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 - github.com/ssvlabs/ssv/ssvsigner v0.0.0-20260414203712-ca63d2dfc121 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 + github.com/ssvlabs/ssv/ssvsigner v0.0.0-20260415125841-05316f77d5e5 github.com/status-im/keycard-go v0.2.0 github.com/stretchr/testify v1.11.1 github.com/wealdtech/go-eth2-types/v2 v2.8.1 diff --git a/go.sum b/go.sum index 08b80f8d7d..bd46d4dc62 100644 --- a/go.sum +++ b/go.sum @@ -733,6 +733,8 @@ github.com/ssvlabs/go-eth2-client v0.6.31-0.20250922150906-26179dd60c9c h1:iNQoR github.com/ssvlabs/go-eth2-client v0.6.31-0.20250922150906-26179dd60c9c/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 h1:yGQ4e0VZa3TTntgd58nArJy6rllrodwkew8hZ1uAeOY= github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index f268191f12..41412e026e 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -244,6 +244,11 @@ func (km *LocalKeyManager) signBeaconObject( return nil, nil, fmt.Errorf("obj type is unknown: %T", obj) } return km.signer.SignRegistration(data, domain, pubKey[:]) + case spectypes.DomainPTCAttester: + // Gloas (ePBS) PTC payload attestation: a plain BLS signature over the SSZ root under + // DomainPTCAttester, with no slashing protection (it is not in the slashing predicate). + // SignAggregateAndProof is the generic ssz.HashRoot signer; reuse it. + return km.signer.SignAggregateAndProof(obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") } diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index ad7af633a1..1b98a48484 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -402,6 +402,10 @@ func (km *RemoteKeyManager) prepareSignRequest( req.Type = web3signer.TypeValidatorRegistration req.ValidatorRegistration = data + case spectypes.DomainPTCAttester: + // Gloas (ePBS) PTC payload attestations have no Web3Signer request type, so the remote + // signer cannot sign them until Web3Signer adds support; PTC requires local signing. + return web3signer.SignRequest{}, phase0.Root{}, errors.New("payload attestation signing is not supported by the remote signer") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") } diff --git a/ssvsigner/go.mod b/ssvsigner/go.mod index 746981156f..d3f98aa88a 100644 --- a/ssvsigner/go.mod +++ b/ssvsigner/go.mod @@ -30,7 +30,7 @@ require ( github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 github.com/sourcegraph/conc v0.3.0 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/valyala/fasthttp v1.58.0 diff --git a/ssvsigner/go.sum b/ssvsigner/go.sum index e9456d62eb..81b0d199ab 100644 --- a/ssvsigner/go.sum +++ b/ssvsigner/go.sum @@ -187,8 +187,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/ssvlabs/eth2-key-manager v1.5.6 h1:BMxVCsbcIlUiiO0hpePkHxzX0yhKgMkEzVSoNmSXySM= github.com/ssvlabs/eth2-key-manager v1.5.6/go.mod h1:tjzhmMzrc0Lzc/OMW1h9Mz8AhmKH7FQC/nFiMNJ0bd8= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 h1:yGQ4e0VZa3TTntgd58nArJy6rllrodwkew8hZ1uAeOY= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 523151c74c3a4ad85350a01103ecf7bfa2497072 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 10:37:24 +0300 Subject: [PATCH 009/150] gloas: add PTC attester runner on ssv-spec ePBS constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the ePBS roles/domains/partial-sig types now in ssv-spec (ssvlabs/ssv-spec#632) and add the PTC attestation runner (SIP #94 §3): - bump ssv-spec to the branch carrying the constants - collapse the node-side gloas placeholders onto spectypes.* (roles, domains, partial-sig types, and the now-redundant beacon->runner role mapping, which ssv-spec's MapDutyToRunnerRole handles); gloas keeps only the wire types - add PTCAttesterRunner: a no-consensus partial-signature runner that freezes its beacon node's PayloadAttestationData observation, signs it under DomainPTCAttester, and reconstructs + submits one PayloadAttestationMessage per validator once a threshold of operators converge on byte-identical data --- protocol/v2/ssv/runner/ptc_attester.go | 225 ++++++++++++++++++++++ protocol/v2/types/gloas/constants.go | 66 ------- protocol/v2/types/gloas/constants_test.go | 47 ----- protocol/v2/types/gloas/doc.go | 5 + protocol/v2/types/runner_role.go | 6 - protocol/v2/types/runner_role_test.go | 6 +- 6 files changed, 232 insertions(+), 123 deletions(-) create mode 100644 protocol/v2/ssv/runner/ptc_attester.go delete mode 100644 protocol/v2/types/gloas/constants.go delete mode 100644 protocol/v2/types/gloas/constants_test.go create mode 100644 protocol/v2/types/gloas/doc.go diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go new file mode 100644 index 0000000000..cadcbec318 --- /dev/null +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -0,0 +1,225 @@ +package runner + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + spectypes "github.com/ssvlabs/ssv-spec/types" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/observability/log/fields" + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" + ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" +) + +var _ Runner = (*PTCAttesterRunner)(nil) + +// PTCAttesterRunner runs the Gloas (ePBS) Payload Timeliness Committee attestation duty (SIP #94 §3). +// It has no consensus or pre-consensus negotiation: each operator signs the PayloadAttestationData its +// own beacon node reports at execution time, and a per-validator signature reconstructs only once a +// threshold of operators converged on byte-identical data — honest convergence, not consensus. +type PTCAttesterRunner struct { + *BaseRunner + + beacon beacon.BeaconNode + network protocolp2p.Network + signer ekm.BeaconSigner + operatorSigner ssvtypes.OperatorSigner + + // payloadAttestationData is the operator's frozen observation. Incoming partial signatures are + // validated and aggregated against exactly this root; nil means the operator abstained (saw no + // block) and is sitting the duty out. + payloadAttestationData *gloas.PayloadAttestationData +} + +// PTCAttesterRunnerOptions bundles the dependencies required by NewPTCAttesterRunner. +type PTCAttesterRunnerOptions struct { + BaseRunnerOptions +} + +func NewPTCAttesterRunner(opts PTCAttesterRunnerOptions) (Runner, error) { + if len(opts.Share) != 1 { + return nil, fmt.Errorf("must have one share") + } + + return &PTCAttesterRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RolePTCAttester, + NetworkConfig: opts.NetworkConfig, + Share: opts.Share, + }, + + beacon: opts.Beacon, + network: opts.Network, + signer: opts.Signer, + operatorSigner: opts.OperatorSigner, + }, nil +} + +func (r *PTCAttesterRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) +} + +func (r *PTCAttesterRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + hasQuorum, roots, err := r.basePreConsensusMsgProcessing(ctx, logger, r, signedMsg) + if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { + // The runner is reused across duties, so a late message for a finished duty is retryable. + err = NewRetryableError(err) + } + if err != nil { + return fmt.Errorf("failed processing payload attestation message: %w", err) + } + + // quorum returns true only once (the first time it is reached). + if !hasQuorum { + return nil + } + + if r.payloadAttestationData == nil { + return fmt.Errorf("reached quorum without a frozen payload attestation data") + } + + // only 1 root, verified in basePreConsensusMsgProcessing + root := roots[0] + fullSig, err := r.State.ReconstructBeaconSig(r.State.PreConsensusContainer, root, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) + if err != nil { + // If the reconstructed signature is invalid, surface which partial signatures were at fault. + r.FallBackAndVerifyEachSignature(r.State.PreConsensusContainer, root, r.GetShare().Committee, r.GetShare().ValidatorIndex) + return fmt.Errorf("got pre-consensus quorum but it has invalid signatures: %w", err) + } + var signature phase0.BLSSignature + copy(signature[:], fullSig) + + msg := &gloas.PayloadAttestationMessage{ + ValidatorIndex: r.GetShare().ValidatorIndex, + Data: r.payloadAttestationData, + Signature: signature, + } + if err := r.beacon.SubmitPayloadAttestationMessages(ctx, []*gloas.PayloadAttestationMessage{msg}); err != nil { + return fmt.Errorf("could not submit payload attestation message: %w", err) + } + + r.markDutySucceeded() + logger.Info("✔️ successfully submitted payload attestation", fields.Slot(r.payloadAttestationData.Slot)) + return nil +} + +func (r *PTCAttesterRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { + return fmt.Errorf("no consensus phase for PTC attestation") +} + +func (r *PTCAttesterRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return fmt.Errorf("no post-consensus phase for PTC attestation") +} + +func (r *PTCAttesterRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + if r.payloadAttestationData == nil { + return nil, spectypes.DomainError, fmt.Errorf("no frozen payload attestation data") + } + return []ssz.HashRoot{r.payloadAttestationData}, phase0.DomainType(spectypes.DomainPTCAttester), nil +} + +func (r *PTCAttesterRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for PTC attestation") +} + +func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + slot := validatorDuty.DutySlot() + + // Observe the slot's payload-attestation data from our own beacon node. Per SIP #94 §3, an + // operator that has seen no beacon block for the slot abstains (signs and submits nothing). + data, err := r.beacon.PayloadAttestationData(ctx, slot) + if err != nil { + logger.Debug("abstaining from PTC attestation: no payload attestation data", fields.Slot(slot), zap.Error(err)) + r.markDutySucceeded() + return nil + } + if data.BeaconBlockRoot == (phase0.Root{}) { + logger.Debug("abstaining from PTC attestation: no beacon block for slot", fields.Slot(slot)) + r.markDutySucceeded() + return nil + } + + // Freeze the observation: peers' partial signatures are validated and aggregated against exactly + // this root, so only operators that converged on identical data reach quorum. + r.payloadAttestationData = data + + msg, err := signBeaconObject(ctx, r, r.NetworkConfig, validatorDuty, data, slot, phase0.DomainType(spectypes.DomainPTCAttester)) + if err != nil { + return fmt.Errorf("could not sign payload attestation data: %w", err) + } + + msgs := &spectypes.PartialSignatureMessages{ + Type: spectypes.PTCAttesterPartialSig, + Slot: slot, + Messages: []*spectypes.PartialSignatureMessage{msg}, + } + + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + return fmt.Errorf("could not sign/broadcast payload attestation partial sig: %w", err) + } + return nil +} + +func (r *PTCAttesterRunner) GetNetwork() protocolp2p.Network { return r.network } + +func (r *PTCAttesterRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } + +func (r *PTCAttesterRunner) GetSigner() ekm.BeaconSigner { return r.signer } + +func (r *PTCAttesterRunner) GetOperatorSigner() ssvtypes.OperatorSigner { return r.operatorSigner } + +// Only BaseRunner is persisted; the frozen observation is transient per-duty state. +func (r *PTCAttesterRunner) MarshalJSON() ([]byte, error) { + type ptcAttesterRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + return json.Marshal(&ptcAttesterRunnerJSON{BaseRunner: r.BaseRunner}) +} + +func (r *PTCAttesterRunner) UnmarshalJSON(data []byte) error { + type ptcAttesterRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + aux := &ptcAttesterRunnerJSON{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if aux.BaseRunner == nil { + return fmt.Errorf("missing BaseRunner") + } + r.BaseRunner = aux.BaseRunner + return nil +} + +func (r *PTCAttesterRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *PTCAttesterRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *PTCAttesterRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + return [32]byte{}, fmt.Errorf("could not encode PTCAttesterRunner: %w", err) + } + return sha256.Sum256(marshaledRoot), nil +} diff --git a/protocol/v2/types/gloas/constants.go b/protocol/v2/types/gloas/constants.go deleted file mode 100644 index 651354b405..0000000000 --- a/protocol/v2/types/gloas/constants.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package gloas holds the node-side protocol/wire types and constants introduced by -// ePBS (EIP-7732 / Gloas), per SIP ssvlabs/SIPs#94. They are defined node-side as -// values of the existing spectypes base types, slotting above the consolidated Boole -// roles (max RoleAggregatorCommittee = 6). -// -// The wire values here are protocol-canonical and MUST match the Anchor (Rust) client; -// do not change them without a coordinated cross-client + SIP update. -package gloas - -import ( - spectypes "github.com/ssvlabs/ssv-spec/types" -) - -// Runner roles for the three new ePBS duties. The deprecated RunnerRole 1/3 -// (RoleAggregator / RoleSyncCommitteeContribution) stay reserved for pre-consolidation -// back-compat decoding — see protocol/v2/types/runner_role.go. -const ( - RolePTCAttester = spectypes.RunnerRole(7) // §3 payload-attestation (PTC) attester - RoleProposerPreferences = spectypes.RunnerRole(8) // §5 proposer preferences - RoleEnvelopeBuilder = spectypes.RunnerRole(9) // §6 execution-payload envelope -) - -// Beacon (duty) roles mirroring the runner roles above. Existing BeaconRole values -// run 0..6 (BNRoleVoluntaryExit = 6), so 7/8/9 are the next free slots. -const ( - BNRolePTCAttester = spectypes.BeaconRole(7) - BNRoleProposerPreferences = spectypes.BeaconRole(8) - BNRoleEnvelopeBuilder = spectypes.BeaconRole(9) -) - -// Partial-signature message types. Existing values run up to -// AggregatorCommitteePartialSig = 6. The §6 envelope duty adds no type of its own: -// its post-consensus reuses PostConsensusPartialSig, discriminated by runner role. -const ( - // PTCAttesterPartialSig is the partial signature over PayloadAttestationData (§3). - PTCAttesterPartialSig = spectypes.PartialSigMsgType(7) - // ProposerPreferencesPartialSig is the partial signature over ProposerPreferences (§5). - ProposerPreferencesPartialSig = spectypes.PartialSigMsgType(8) -) - -// Beacon signing domains introduced by Gloas — consensus-spec domains (4 bytes, -// domain number in byte[0], matching the spectypes.Domain* style). -var ( - // DomainBeaconBuilder signs the (blinded) ExecutionPayloadEnvelope (§6). - DomainBeaconBuilder = [4]byte{0x0b, 0x00, 0x00, 0x00} - // DomainPTCAttester signs PayloadAttestationData (§3). - DomainPTCAttester = [4]byte{0x0c, 0x00, 0x00, 0x00} - // DomainProposerPreferences signs ProposerPreferences (§5). - DomainProposerPreferences = [4]byte{0x0d, 0x00, 0x00, 0x00} -) - -// RunnerRoleForBeaconRole maps a Gloas (ePBS) beacon duty role to its runner role, -// reporting ok=false for non-Gloas roles. ssv-spec's ValidatorDuty.RunnerRole() predates -// these roles, so callers apply this mapping node-side before delegating to it. -func RunnerRoleForBeaconRole(role spectypes.BeaconRole) (spectypes.RunnerRole, bool) { - switch role { - case BNRolePTCAttester: - return RolePTCAttester, true - case BNRoleProposerPreferences: - return RoleProposerPreferences, true - case BNRoleEnvelopeBuilder: - return RoleEnvelopeBuilder, true - default: - return spectypes.RoleUnknown, false - } -} diff --git a/protocol/v2/types/gloas/constants_test.go b/protocol/v2/types/gloas/constants_test.go deleted file mode 100644 index 65d02b3b5c..0000000000 --- a/protocol/v2/types/gloas/constants_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package gloas - -import ( - "testing" - - spectypes "github.com/ssvlabs/ssv-spec/types" - "github.com/stretchr/testify/require" -) - -// TestWireConstants pins the SIP-canonical wire values. These MUST match the Anchor -// client byte-for-byte — a change here is a cross-client wire break, not a refactor. -func TestWireConstants(t *testing.T) { - require.Equal(t, spectypes.RunnerRole(7), RolePTCAttester) - require.Equal(t, spectypes.RunnerRole(8), RoleProposerPreferences) - require.Equal(t, spectypes.RunnerRole(9), RoleEnvelopeBuilder) - - require.Equal(t, spectypes.BeaconRole(7), BNRolePTCAttester) - require.Equal(t, spectypes.BeaconRole(8), BNRoleProposerPreferences) - require.Equal(t, spectypes.BeaconRole(9), BNRoleEnvelopeBuilder) - - require.Equal(t, spectypes.PartialSigMsgType(7), PTCAttesterPartialSig) - require.Equal(t, spectypes.PartialSigMsgType(8), ProposerPreferencesPartialSig) - - require.Equal(t, [4]byte{0x0b, 0x00, 0x00, 0x00}, DomainBeaconBuilder) - require.Equal(t, [4]byte{0x0c, 0x00, 0x00, 0x00}, DomainPTCAttester) - require.Equal(t, [4]byte{0x0d, 0x00, 0x00, 0x00}, DomainProposerPreferences) -} - -func TestRunnerRoleForBeaconRole(t *testing.T) { - for _, tc := range []struct { - name string - role spectypes.BeaconRole - want spectypes.RunnerRole - ok bool - }{ - {"ptc", BNRolePTCAttester, RolePTCAttester, true}, - {"proposer_preferences", BNRoleProposerPreferences, RoleProposerPreferences, true}, - {"envelope", BNRoleEnvelopeBuilder, RoleEnvelopeBuilder, true}, - {"non_gloas", spectypes.BNRoleAttester, spectypes.RoleUnknown, false}, - } { - t.Run(tc.name, func(t *testing.T) { - got, ok := RunnerRoleForBeaconRole(tc.role) - require.Equal(t, tc.ok, ok) - require.Equal(t, tc.want, got) - }) - } -} diff --git a/protocol/v2/types/gloas/doc.go b/protocol/v2/types/gloas/doc.go new file mode 100644 index 0000000000..13a97e294d --- /dev/null +++ b/protocol/v2/types/gloas/doc.go @@ -0,0 +1,5 @@ +// Package gloas holds the node-side wire types introduced by ePBS (EIP-7732 / Gloas), per +// SIP ssvlabs/SIPs#94 — the payload-attestation containers, the PTC duty, and the Gloas +// BeaconVote variant. The associated roles, domains, and partial-signature types live in +// ssv-spec (spectypes); only types ssv-spec does not yet carry are defined here. +package gloas diff --git a/protocol/v2/types/runner_role.go b/protocol/v2/types/runner_role.go index e4b11507d8..d5ee5e41ea 100644 --- a/protocol/v2/types/runner_role.go +++ b/protocol/v2/types/runner_role.go @@ -2,8 +2,6 @@ package types import ( spectypes "github.com/ssvlabs/ssv-spec/types" - - "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) const ( @@ -27,10 +25,6 @@ func RunnerRoleForValidatorDuty(duty *spectypes.ValidatorDuty, isBooleFork bool) if duty == nil { return spectypes.RoleUnknown } - // Gloas (ePBS) duties post-date ssv-spec's RunnerRole(); resolve them node-side first. - if role, ok := gloas.RunnerRoleForBeaconRole(duty.Type); ok { - return role - } if isBooleFork { return duty.RunnerRole() } diff --git a/protocol/v2/types/runner_role_test.go b/protocol/v2/types/runner_role_test.go index ea07568fb2..68623ec8df 100644 --- a/protocol/v2/types/runner_role_test.go +++ b/protocol/v2/types/runner_role_test.go @@ -6,8 +6,6 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func TestCommitteeRunnerRoleForBeaconRole(t *testing.T) { @@ -78,8 +76,8 @@ func TestRunnerRoleForDuty_CommitteeDuty(t *testing.T) { } func TestRunnerRoleForValidatorDuty_Gloas(t *testing.T) { - duty := &spectypes.ValidatorDuty{Type: gloas.BNRolePTCAttester} - require.Equal(t, gloas.RolePTCAttester, RunnerRoleForValidatorDuty(duty, true)) + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRolePTCAttester} + require.Equal(t, spectypes.RolePTCAttester, RunnerRoleForValidatorDuty(duty, true)) require.Equal(t, spectypes.RoleUnknown, RunnerRoleForValidatorDuty(nil, true)) // Non-Gloas roles still resolve via ssv-spec's RunnerRole(). From 7ab07f3dff03e83c7fc990778b5c6177f45c58a9 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 11:01:14 +0300 Subject: [PATCH 010/150] gloas: register PTCAttesterRunner and add runner unit tests - construct PTCAttesterRunner under RolePTCAttester in SetupRunners (unconditional, so a validator loaded pre-Gloas has it ready when the fork activates; duty-gating happens at the scheduler). The per-role message queue is derived from the runner set, so no separate wiring is needed. - unit tests for the share-count guard, the frozen-observation pre-consensus root (the honest-convergence mechanism), and the no-consensus-phase rejections. --- operator/validator/controller.go | 5 ++ protocol/v2/ssv/runner/ptc_attester_test.go | 51 +++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 protocol/v2/ssv/runner/ptc_attester_test.go diff --git a/operator/validator/controller.go b/operator/validator/controller.go index cfc1e523f0..e48c3bf4ec 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1172,6 +1172,7 @@ func SetupRunners( ssvtypes.RoleSyncCommitteeContribution, spectypes.RoleValidatorRegistration, spectypes.RoleVoluntaryExit, + spectypes.RolePTCAttester, } buildController := func(role spectypes.RunnerRole) *qbftcontroller.Controller { @@ -1273,6 +1274,10 @@ func SetupRunners( runners[role], err = runner.NewVoluntaryExitRunner(runner.VoluntaryExitRunnerOptions{ BaseRunnerOptions: baseOpts, }) + case spectypes.RolePTCAttester: + runners[role], err = runner.NewPTCAttesterRunner(runner.PTCAttesterRunnerOptions{ + BaseRunnerOptions: baseOpts, + }) default: return nil, fmt.Errorf("unexpected duty runner type: %s", role) } diff --git a/protocol/v2/ssv/runner/ptc_attester_test.go b/protocol/v2/ssv/runner/ptc_attester_test.go new file mode 100644 index 0000000000..92770e99c8 --- /dev/null +++ b/protocol/v2/ssv/runner/ptc_attester_test.go @@ -0,0 +1,51 @@ +package runner + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +func TestNewPTCAttesterRunner_RequiresSingleShare(t *testing.T) { + _, err := NewPTCAttesterRunner(PTCAttesterRunnerOptions{}) + require.Error(t, err) + + r, err := NewPTCAttesterRunner(PTCAttesterRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {}}, + }, + }) + require.NoError(t, err) + require.Equal(t, spectypes.RolePTCAttester, r.(*PTCAttesterRunner).BaseRunner.RunnerRoleType) +} + +// The runner validates and aggregates incoming partial signatures against its own frozen +// observation: there is no expected root before executeDuty has observed and frozen one, and +// afterwards it is exactly that observation's root under DomainPTCAttester. +func TestPTCAttesterRunner_ExpectedPreConsensusRootsAndDomain(t *testing.T) { + r := &PTCAttesterRunner{} + + _, _, err := r.expectedPreConsensusRootsAndDomain() + require.Error(t, err) + + data := &gloas.PayloadAttestationData{BeaconBlockRoot: phase0.Root{0x01}, Slot: 5, PayloadPresent: true} + r.payloadAttestationData = data + roots, domain, err := r.expectedPreConsensusRootsAndDomain() + require.NoError(t, err) + require.Equal(t, []ssz.HashRoot{data}, roots) + require.Equal(t, phase0.DomainType(spectypes.DomainPTCAttester), domain) +} + +// PTC has no consensus or post-consensus phase; those entry points must reject. +func TestPTCAttesterRunner_NoConsensusPhases(t *testing.T) { + r := &PTCAttesterRunner{} + require.Error(t, r.ProcessConsensus(context.Background(), zap.NewNop(), nil)) + require.Error(t, r.ProcessPostConsensus(context.Background(), zap.NewNop(), nil)) +} From ebf94297c90c005d595c233a7dfe2150b8610ca7 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 11:25:19 +0300 Subject: [PATCH 011/150] gloas: add PTC attestation scheduler handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule the Gloas (ePBS) PTC attestation duty (SIP #94 §3): - add PayloadAttestationDuties to the scheduler's BeaconNode (prefetchingBeacon passes through to its inner node) and regenerate the mock - PTCAttestationHandler fetches PTC duties per epoch (Gloas-gated) and fires each at the 75%-of-slot cutoff, so the runner observes payload presence then and runs its partial-signature round in the [75%, 100%] window - register it in NewScheduler (operator mode) --- operator/duties/beacon_adapter.go | 6 ++ operator/duties/ptc_attestation.go | 126 +++++++++++++++++++++++++++++ operator/duties/scheduler.go | 3 + operator/duties/scheduler_mock.go | 16 ++++ 4 files changed, 151 insertions(+) create mode 100644 operator/duties/ptc_attestation.go diff --git a/operator/duties/beacon_adapter.go b/operator/duties/beacon_adapter.go index 27a2d95856..fa454988a9 100644 --- a/operator/duties/beacon_adapter.go +++ b/operator/duties/beacon_adapter.go @@ -15,6 +15,7 @@ import ( goclient "github.com/ssvlabs/ssv/beacon/goclient" "github.com/ssvlabs/ssv/networkconfig" beaconprotocol "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type validatorPubkeyProvider interface { @@ -320,5 +321,10 @@ func (p *prefetchingBeacon) SubscribeToHeadEvents(ctx context.Context, subscribe return p.inner.SubscribeToHeadEvents(ctx, subscriberIdentifier, ch) } +func (p *prefetchingBeacon) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + // Pass-through + return p.inner.PayloadAttestationDuties(ctx, epoch, indices) +} + // Ensure conformance to the scheduler BeaconNode subset (defined in scheduler.go). var _ BeaconNode = (*prefetchingBeacon)(nil) diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go new file mode 100644 index 0000000000..af63d21ab5 --- /dev/null +++ b/operator/duties/ptc_attestation.go @@ -0,0 +1,126 @@ +package duties + +import ( + "context" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/observability/log/fields" +) + +// PTCAttestationHandler schedules the Gloas (ePBS) Payload Timeliness Committee attestation duty +// (SIP #94 §3): it fetches PTC duties per epoch and, for each slot holding one, executes it at the +// 75%-of-slot cutoff so the runner observes payload presence at that point and runs its +// partial-signature round in the otherwise-free [75%, 100%] window. +type PTCAttestationHandler struct { + baseHandler + + // duties caches fetched duties as ready-to-execute ValidatorDuties, keyed by epoch then slot. + // Accessed only from the HandleDuties goroutine. + duties map[phase0.Epoch]map[phase0.Slot][]*spectypes.ValidatorDuty +} + +func NewPTCAttestationHandler() *PTCAttestationHandler { + return &PTCAttestationHandler{ + duties: map[phase0.Epoch]map[phase0.Slot][]*spectypes.ValidatorDuty{}, + } +} + +func (h *PTCAttestationHandler) Name() string { + return spectypes.BNRolePTCAttester.String() +} + +func (h *PTCAttestationHandler) WaitShutdown() {} + +func (h *PTCAttestationHandler) HandleDuties(ctx context.Context) { + h.logger.Info("starting duty handler") + defer h.logger.Info("duty handler exited") + + next := h.ticker.Next() + for { + select { + case <-ctx.Done(): + return + + case <-next: + slot := h.ticker.Slot() + next = h.ticker.Next() + epoch := h.netCfg.EstimatedEpochAtSlot(slot) + + // PTC is a Gloas-only duty. + if !h.netCfg.IsGloas(epoch) { + continue + } + + h.fetchDuties(ctx, epoch) + if h.shouldFetchNextEpoch(slot) { + h.fetchDuties(ctx, epoch+1) + } + h.evictOutdated(epoch) + + if duties := h.duties[epoch][slot]; len(duties) > 0 { + h.scheduleExecution(ctx, slot, duties) + } + + case <-h.indicesChangeCh: + case <-h.reorgEventsCh: + } + } +} + +// fetchDuties fetches and caches an epoch's PTC duties once. +func (h *PTCAttestationHandler) fetchDuties(ctx context.Context, epoch phase0.Epoch) { + if _, cached := h.duties[epoch]; cached { + return + } + + shares := h.validatorProvider.SelfParticipatingValidators(epoch) + indices := make([]phase0.ValidatorIndex, 0, len(shares)) + for _, share := range shares { + indices = append(indices, share.ValidatorIndex) + } + if len(indices) == 0 { + return + } + + ptcDuties, err := h.beaconNode.PayloadAttestationDuties(ctx, epoch, indices) + if err != nil { + h.logger.Warn("failed to fetch PTC duties", fields.Epoch(epoch), zap.Error(err)) + return + } + + bySlot := make(map[phase0.Slot][]*spectypes.ValidatorDuty) + for _, d := range ptcDuties { + bySlot[d.Slot] = append(bySlot[d.Slot], &spectypes.ValidatorDuty{ + Type: spectypes.BNRolePTCAttester, + PubKey: d.PubKey, + ValidatorIndex: d.ValidatorIndex, + Slot: d.Slot, + }) + } + h.duties[epoch] = bySlot + + h.logger.Debug("fetched PTC duties", fields.Epoch(epoch), zap.Int("duties", len(ptcDuties))) +} + +// scheduleExecution fires the duty at the 75%-of-slot cutoff (PAYLOAD_ATTESTATION_DUE_BPS), with a +// deadline at slot end. +func (h *PTCAttestationHandler) scheduleExecution(ctx context.Context, slot phase0.Slot, duties []*spectypes.ValidatorDuty) { + executeAt := h.netCfg.SlotStartTime(slot).Add(h.netCfg.SlotDuration * 3 / 4) + deadline := h.netCfg.SlotStartTime(slot + 1) + time.AfterFunc(time.Until(executeAt), func() { + h.dutiesExecutor.ExecuteDuties(ctx, duties, deadline) + }) +} + +// evictOutdated drops cached duties for epochs before the current one. +func (h *PTCAttestationHandler) evictOutdated(currentEpoch phase0.Epoch) { + for epoch := range h.duties { + if epoch < currentEpoch { + delete(h.duties, epoch) + } + } +} diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index 34d11fac10..73d1b39f67 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -26,6 +26,7 @@ import ( "github.com/ssvlabs/ssv/operator/duties/dutystore" "github.com/ssvlabs/ssv/operator/slotticker" "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) //go:generate go tool -modfile=../../tool.mod mockgen -package=duties -destination=./scheduler_mock.go -source=./scheduler.go @@ -55,6 +56,7 @@ type BeaconNode interface { AttesterDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*eth2apiv1.AttesterDuty, error) ProposerDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*eth2apiv1.ProposerDuty, error) SyncCommitteeDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*eth2apiv1.SyncCommitteeDuty, error) + PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscription []*eth2apiv1.BeaconCommitteeSubscription) error SubmitSyncCommitteeSubscriptions(ctx context.Context, subscription []*eth2apiv1.SyncCommitteeSubscription) error SubscribeToHeadEvents(ctx context.Context, subscriberIdentifier string, ch chan<- *eth2apiv1.HeadEvent) error @@ -179,6 +181,7 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler { NewCommitteeHandler(dutyStore.Attester, dutyStore.SyncCommittee, true), NewValidatorRegistrationHandler(opts.ValidatorRegistrationCh), NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh), + NewPTCAttestationHandler(), ) } return s diff --git a/operator/duties/scheduler_mock.go b/operator/duties/scheduler_mock.go index 4b38653ae0..e086a3fbc9 100644 --- a/operator/duties/scheduler_mock.go +++ b/operator/duties/scheduler_mock.go @@ -20,6 +20,7 @@ import ( types "github.com/ethereum/go-ethereum/core/types" types0 "github.com/ssvlabs/ssv-spec/types" types1 "github.com/ssvlabs/ssv/protocol/v2/types" + gloas "github.com/ssvlabs/ssv/protocol/v2/types/gloas" gomock "go.uber.org/mock/gomock" zap "go.uber.org/zap" ) @@ -159,6 +160,21 @@ func (mr *MockBeaconNodeMockRecorder) AttesterDuties(ctx, epoch, validatorIndice return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttesterDuties", reflect.TypeOf((*MockBeaconNode)(nil).AttesterDuties), ctx, epoch, validatorIndices) } +// PayloadAttestationDuties mocks base method. +func (m *MockBeaconNode) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationDuties", ctx, epoch, validatorIndices) + ret0, _ := ret[0].([]*gloas.PTCDuty) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationDuties indicates an expected call of PayloadAttestationDuties. +func (mr *MockBeaconNodeMockRecorder) PayloadAttestationDuties(ctx, epoch, validatorIndices any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationDuties", reflect.TypeOf((*MockBeaconNode)(nil).PayloadAttestationDuties), ctx, epoch, validatorIndices) +} + // ProposerDuties mocks base method. func (m *MockBeaconNode) ProposerDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*v1.ProposerDuty, error) { m.ctrl.T.Helper() From 2a82615e70cd429c7b307fc32b21c5e5cbc16218 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 11:46:43 +0300 Subject: [PATCH 012/150] message/validation: accept Gloas (ePBS) PTC attestation messages Let RolePTCAttester / PTCAttesterPartialSig messages pass validation once Gloas is active: - validRoleAtSlot accepts RolePTCAttester when the slot is in the Gloas fork - reject consensus messages for PTC (it has no consensus phase), as for ValidatorRegistration and VoluntaryExit - PTCAttesterPartialSig is a valid type that matches RolePTCAttester and counts toward the pre-consensus message limit and accounting --- message/validation/consensus_validation.go | 4 ++-- message/validation/partial_validation.go | 9 +++++++-- message/validation/seen_msg_types.go | 2 +- message/validation/signed_ssv_message.go | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/message/validation/consensus_validation.go b/message/validation/consensus_validation.go index be499112b4..0937e896de 100644 --- a/message/validation/consensus_validation.go +++ b/message/validation/consensus_validation.go @@ -159,8 +159,8 @@ func (mv *messageValidator) validateConsensusMessageSemantics( return e } - // Rule: Duty role has consensus (true except for ValidatorRegistration and VoluntaryExit) - if role == spectypes.RoleValidatorRegistration || role == spectypes.RoleVoluntaryExit { + // Rule: Duty role has consensus (true except for ValidatorRegistration, VoluntaryExit, and PTC attestation) + if role == spectypes.RoleValidatorRegistration || role == spectypes.RoleVoluntaryExit || role == spectypes.RolePTCAttester { e := ErrUnexpectedConsensusMessage e.got = role return e diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 649b031e07..c1aaae9892 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -121,6 +121,7 @@ func (mv *messageValidator) validatePartialSignatureMessageSemantics( // - SelectionProofPartialSig or PostConsensusPartialSig for Sync committee contribution // - ValidatorRegistrationPartialSig for Validator Registration // - VoluntaryExitPartialSig for Voluntary Exit + // - PTCAttesterPartialSig for PTC attestation if !mv.partialSignatureTypeMatchesRole(partialSignatureMessages.Type, role) { return ErrPartialSignatureTypeRoleMismatch } @@ -196,6 +197,7 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic( // - 1 AggregatorCommitteePartialSig and 1 PostConsensusPartialSig for AggregatorCommittee // - 1 ValidatorRegistrationPartialSig for Validator Registration // - 1 VoluntaryExitPartialSig for Voluntary Exit + // - 1 PTCAttesterPartialSig for PTC attestation if err := validatePartialSignatureMessageLimit(partialSignatureMessages, receivedFrom, signerState); err != nil { return err } @@ -279,7 +281,7 @@ func validatePartialSignatureMessageLimit( switch m.Type { case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, - spectypes.AggregatorCommitteePartialSig: + spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPreConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. e := ErrTooManyPartialSigMessage @@ -354,7 +356,8 @@ func (mv *messageValidator) validPartialSigMsgType(msgType spectypes.PartialSigM ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, - spectypes.AggregatorCommitteePartialSig: + spectypes.AggregatorCommitteePartialSig, + spectypes.PTCAttesterPartialSig: return true default: return false @@ -377,6 +380,8 @@ func (mv *messageValidator) partialSignatureTypeMatchesRole(msgType spectypes.Pa return msgType == spectypes.VoluntaryExitPartialSig case spectypes.RoleAggregatorCommittee: return msgType == spectypes.AggregatorCommitteePartialSig || msgType == spectypes.PostConsensusPartialSig + case spectypes.RolePTCAttester: + return msgType == spectypes.PTCAttesterPartialSig default: return false } diff --git a/message/validation/seen_msg_types.go b/message/validation/seen_msg_types.go index 117e65ce9a..14233ac2bc 100644 --- a/message/validation/seen_msg_types.go +++ b/message/validation/seen_msg_types.go @@ -80,7 +80,7 @@ func (c *SeenMsgTypes) RecordConsensusMessage(signedSSVMessage *spectypes.Signed // RecordPartialSignatureMessage updates the counts based on the provided partial signature message type. func (c *SeenMsgTypes) RecordPartialSignatureMessage(messages *spectypes.PartialSignatureMessages) error { switch messages.Type { - case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig: + case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: c.recordPreConsensus() case spectypes.PostConsensusPartialSig: c.recordPostConsensus() diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index 6ea2b2984b..5f8ef8abf7 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -153,6 +153,7 @@ func (mv *messageValidator) validRoleUnion(roleType spectypes.RunnerRole) bool { func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot phase0.Slot) bool { isInBooleFork := mv.netCfg.BooleForkAtSlot(slot) + isInGloas := mv.netCfg.IsGloas(mv.netCfg.EstimatedEpochAtSlot(slot)) switch roleType { case spectypes.RoleCommittee, spectypes.RoleProposer, spectypes.RoleValidatorRegistration, spectypes.RoleVoluntaryExit: return true @@ -160,6 +161,8 @@ func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot return isInBooleFork case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution: return !isInBooleFork + case spectypes.RolePTCAttester: + return isInGloas default: return false } From 39d1af4b02a54bd6523d13006e90a6672e213952 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 13:24:23 +0300 Subject: [PATCH 013/150] gloas: move DataVersionGloas to networkconfig and tidy fork helpers Review follow-ups: - move the DataVersionGloas placeholder into networkconfig (beside the Forks map), removing the networkconfig -> protocol/v2/types/gloas import inversion and the now-dead gloas.IsGloas(spec.DataVersion); gloas is a pure wire-types leaf again - TODO on BeaconForkAtEpoch (networkconfig + ssvsigner): the list stops at Fulu, so it returns Fulu for a Gloas epoch; IsGloas is the gate today, extend it when activation is wired and callers handle the new version - regenerate the SSZ encodings with the pinned sszgen so the committed artifacts match `go generate` (only the Hash header had drifted) --- beacon/goclient/spec.go | 3 +-- networkconfig/beacon.go | 14 +++++++++++--- networkconfig/beacon_gloas_test.go | 6 ++---- .../v2/types/gloas/beacon_vote_encoding.go | 2 +- protocol/v2/types/gloas/fork.go | 17 ----------------- protocol/v2/types/gloas/fork_test.go | 18 ------------------ .../gloas/payload_attestation_encoding.go | 2 +- ssvsigner/internal/beaconcfg/config.go | 4 ++++ 8 files changed, 20 insertions(+), 46 deletions(-) delete mode 100644 protocol/v2/types/gloas/fork.go delete mode 100644 protocol/v2/types/gloas/fork_test.go diff --git a/beacon/goclient/spec.go b/beacon/goclient/spec.go index 634a6db8d9..c5463aedbe 100644 --- a/beacon/goclient/spec.go +++ b/beacon/goclient/spec.go @@ -14,7 +14,6 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" - "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) const ( @@ -311,7 +310,7 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi CurrentVersion: fuluForkVersion, Epoch: fuluEpoch, }, - gloas.DataVersionGloas: { + networkconfig.DataVersionGloas: { PreviousVersion: fuluForkVersion, CurrentVersion: gloasForkVersion, Epoch: gloasEpoch, diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index 0fa6700ec6..f9546211f6 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -9,10 +9,14 @@ import ( "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/phase0" - - "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) +// DataVersionGloas is a node-side placeholder for the Gloas beacon data version: until +// go-eth2-client defines it (its latest is Fulu), we slot Gloas immediately after Fulu. +// Remove and reconcile with upstream once it ships a real spec.DataVersionGloas. Note +// DataVersionGloas.String() returns "unknown" — the spec string/JSON tables aren't extended. +const DataVersionGloas = spec.DataVersionFulu + 1 + // Beacon defines beacon network configuration. It is fetched from the consensus client during the node runtime. type Beacon struct { Name string @@ -142,6 +146,10 @@ func (b *Beacon) EpochDuration() time.Duration { return b.SlotDuration * time.Duration(b.SlotsPerEpoch) // #nosec G115: slot cannot exceed math.MaxInt64 } +// ForkAtEpoch returns the beacon fork active at the epoch. The versions list stops at +// Fulu, so it returns Fulu for a Gloas epoch; (*Beacon).IsGloas is the Gloas gate today. +// TODO(gloas): extend the list with DataVersionGloas once activation is wired and callers +// that switch on spec.DataVersion handle the new version. func (b *Beacon) ForkAtEpoch(epoch phase0.Epoch) (spec.DataVersion, *phase0.Fork) { versions := []spec.DataVersion{ spec.DataVersionPhase0, @@ -179,7 +187,7 @@ func (b *Beacon) ForkAtVersion(version spec.DataVersion) (phase0.Fork, bool) { // Returns false when there is no scheduled Gloas fork (absent from Forks or far-future), // so it is safe on pre-Gloas networks and Beacon values without a Gloas entry. func (b *Beacon) IsGloas(epoch phase0.Epoch) bool { - fork, ok := b.Forks[gloas.DataVersionGloas] + fork, ok := b.Forks[DataVersionGloas] return ok && epoch >= fork.Epoch } diff --git a/networkconfig/beacon_gloas_test.go b/networkconfig/beacon_gloas_test.go index 079f2c2ade..cd1ff2606f 100644 --- a/networkconfig/beacon_gloas_test.go +++ b/networkconfig/beacon_gloas_test.go @@ -7,8 +7,6 @@ import ( "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" - - "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func TestBeacon_IsGloas(t *testing.T) { @@ -19,13 +17,13 @@ func TestBeacon_IsGloas(t *testing.T) { // Unscheduled Gloas (far-future sentinel) → never Gloas. farFuture := &Beacon{Forks: map[spec.DataVersion]phase0.Fork{ - gloas.DataVersionGloas: {Epoch: phase0.Epoch(math.MaxUint64)}, + DataVersionGloas: {Epoch: phase0.Epoch(math.MaxUint64)}, }} require.False(t, farFuture.IsGloas(1_000_000)) // Scheduled at epoch 100. scheduled := &Beacon{Forks: map[spec.DataVersion]phase0.Fork{ - gloas.DataVersionGloas: {Epoch: 100}, + DataVersionGloas: {Epoch: 100}, }} require.False(t, scheduled.IsGloas(99)) require.True(t, scheduled.IsGloas(100)) diff --git a/protocol/v2/types/gloas/beacon_vote_encoding.go b/protocol/v2/types/gloas/beacon_vote_encoding.go index fca00d8802..97d6f42227 100644 --- a/protocol/v2/types/gloas/beacon_vote_encoding.go +++ b/protocol/v2/types/gloas/beacon_vote_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: f1f3e11890862710cd9e5d35f97f7aba983941f2854040100fc9bceb52253f3e +// Hash: 4080ac6d77c7ac29416f1dabf14c44f39cc124657e58115d522219c7e27be5f0 // Version: 0.1.3 package gloas diff --git a/protocol/v2/types/gloas/fork.go b/protocol/v2/types/gloas/fork.go deleted file mode 100644 index 4bf64aed70..0000000000 --- a/protocol/v2/types/gloas/fork.go +++ /dev/null @@ -1,17 +0,0 @@ -package gloas - -import ( - "github.com/attestantio/go-eth2-client/spec" -) - -// DataVersionGloas is a node-side placeholder for the Gloas beacon data version: until -// go-eth2-client defines it (its latest is Fulu), we slot Gloas immediately after Fulu. -// Remove and reconcile with upstream once it ships a real spec.DataVersionGloas. Note -// DataVersionGloas.String() returns "unknown" — the spec string/JSON tables aren't extended. -const DataVersionGloas = spec.DataVersionFulu + 1 - -// IsGloas reports whether the given beacon data version is Gloas (ePBS). For epoch-level -// fork gating use networkconfig (*Beacon).IsGloas instead. -func IsGloas(v spec.DataVersion) bool { - return v == DataVersionGloas -} diff --git a/protocol/v2/types/gloas/fork_test.go b/protocol/v2/types/gloas/fork_test.go deleted file mode 100644 index 9b36f9a856..0000000000 --- a/protocol/v2/types/gloas/fork_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package gloas - -import ( - "testing" - - "github.com/attestantio/go-eth2-client/spec" - "github.com/stretchr/testify/require" -) - -func TestDataVersionGloas_Placeholder(t *testing.T) { - // Gloas slots immediately after the current upstream max (Fulu = 7). If this fails, - // go-eth2-client's DataVersion enum shifted — reconcile the placeholder. - require.Equal(t, spec.DataVersion(8), DataVersionGloas) - - require.True(t, IsGloas(DataVersionGloas)) - require.False(t, IsGloas(spec.DataVersionFulu)) - require.False(t, IsGloas(spec.DataVersionElectra)) -} diff --git a/protocol/v2/types/gloas/payload_attestation_encoding.go b/protocol/v2/types/gloas/payload_attestation_encoding.go index 947c4806b1..f739f104ff 100644 --- a/protocol/v2/types/gloas/payload_attestation_encoding.go +++ b/protocol/v2/types/gloas/payload_attestation_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 1b95ae641e75632a5fb0e30aed38d36275e732adcca842bf04dadaa2ee75fc69 +// Hash: 8e151b578e222df02ceda6786c1d1357c4b312a67ce83d18eeaec20d03ea7714 // Version: 0.1.3 package gloas diff --git a/ssvsigner/internal/beaconcfg/config.go b/ssvsigner/internal/beaconcfg/config.go index b5f2869ff3..7c6e05759b 100644 --- a/ssvsigner/internal/beaconcfg/config.go +++ b/ssvsigner/internal/beaconcfg/config.go @@ -56,6 +56,10 @@ func (b *Config) EpochDuration() time.Duration { return b.SlotDuration * time.Duration(b.SlotsPerEpoch) // #nosec G115 } +// ForkAtEpoch returns the beacon fork active at the epoch. The versions list stops at +// Fulu, so it returns Fulu for a Gloas epoch. +// TODO(gloas): extend the list with the Gloas data version once activation is wired and +// callers that switch on spec.DataVersion handle the new version. func (b *Config) ForkAtEpoch(epoch phase0.Epoch) (spec.DataVersion, *phase0.Fork) { versions := []spec.DataVersion{ spec.DataVersionPhase0, From d614d75c270bd4fdb23201db4c8b399e9a335fbd Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 13:24:24 +0300 Subject: [PATCH 014/150] networkconfig: add GlamsterdamDevnet (ePBS devnet) stub An SSV config for the ethpandaops Glamsterdam/Gloas devnet (seeded for devnet-5), for the PTC e2e: a distinct domain, Boole active from genesis, registered in supportedSSVConfigs. The beacon config (genesis/forks incl. GLOAS_FORK_EPOCH) comes from the BN; the registry contract address + sync offset, bootnodes, and validator count are placeholders to fill after the devnet contract deploy + validator registration. --- networkconfig/glamsterdam-devnet.go | 37 +++++++++++++++++++++++++++++ networkconfig/ssv.go | 2 ++ 2 files changed, 39 insertions(+) create mode 100644 networkconfig/glamsterdam-devnet.go diff --git a/networkconfig/glamsterdam-devnet.go b/networkconfig/glamsterdam-devnet.go new file mode 100644 index 0000000000..dae84c868d --- /dev/null +++ b/networkconfig/glamsterdam-devnet.go @@ -0,0 +1,37 @@ +package networkconfig + +import ( + "math/big" + + ethcommon "github.com/ethereum/go-ethereum/common" + + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +// GlamsterdamDevnetSSV is the SSV config for running against an ethpandaops Glamsterdam (Gloas / +// ePBS) devnet — seeded for devnet-5 (chain 7095321190, genesis 1780577940). The beacon config +// (genesis, fork schedule incl. GLOAS_FORK_EPOCH) is read from the BN at runtime; only the +// SSV-side values live here. +// +// Devnets are ephemeral and the SSV contracts are deployed per-network, so the three values +// marked TODO must be filled after the contract deploy + validator registration, and re-checked +// whenever the devnet is reset (or replaced by devnet-6+). +var GlamsterdamDevnetSSV = &SSV{ + Name: "glamsterdam-devnet", + DomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x00}, + NextDomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x01}, + + // TODO(e2e): SSV contract address + its deployment block, set after deploying on the devnet EL. + RegistryContractAddr: ethcommon.Address{}, + RegistrySyncOffset: big.NewInt(0), + + DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, + // TODO(e2e): the 4 operators' bootnode ENRs (discovery seeds for the cluster). + Bootnodes: nil, + + // TODO(e2e): approximate devnet validator count; feeds gossip message-rate scoring only. + TotalEthereumValidators: 1000, + + // Boole is the SSV protocol baseline ePBS builds on — active from genesis on the devnet. + Forks: SSVForks{Boole: 0}, +} diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index 5292e38a07..e1cb1a4d97 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -21,6 +21,8 @@ var supportedSSVConfigs = map[string]*SSV{ HoodiSSV.Name: HoodiSSV, HoodiStageSSV.Name: HoodiStageSSV, SepoliaSSV.Name: SepoliaSSV, + + GlamsterdamDevnetSSV.Name: GlamsterdamDevnetSSV, } func SSVConfigByName(name string) (*SSV, error) { From 03a60de57d4a8d175d672ffe2502252d37119beb Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 13:47:12 +0300 Subject: [PATCH 015/150] docker: exclude tla/ from the build context The Go build doesn't use the TLA+ specs, and tla/states accumulates large gitignored model-checker scratch that bloats `COPY . .`. Exclude tla/ alongside the other non-build dirs (docs, e2e). --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index b472f74216..ab2dc87a66 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ bin data docs e2e +tla .cache .github coverage.out From df1b0d68a8e54911cbbce2c7a7ed8429ecf14a0f Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 14:57:46 +0300 Subject: [PATCH 016/150] ptc: address code-review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - goclient: build PTC requests from the unmasked beacon address — Client.Address() is log-masked, which breaks auth'd/path-prefixed beacon nodes; and scope firstClientResult's timeout per client so a hung primary can't starve fallbacks - runner: warn (not debug) when a beacon-node failure forces a PTC abstain so it isn't dropped silently; clear the frozen observation in StartNewDuty - scheduler: baseline PTC lateness from the payload-attestation cutoff, not slot start, to avoid a false "late execution" warning — shared via a new Beacon.PayloadAttestationCutoff helper - ekm: name the PTC root signer (signSSZRoot) instead of reusing SignAggregateAndProof inline; clearer remote-signer error with the bounded-by-f note - gloas: note GloasBeaconVote is the not-yet-wired committee-runner foundation - add PTCAttestationHandler tests: fetch caching, eviction, and cutoff scheduling --- beacon/goclient/goclient.go | 10 +- beacon/goclient/ptc.go | 21 +++-- networkconfig/beacon.go | 6 ++ operator/duties/ptc_attestation.go | 5 +- operator/duties/ptc_attestation_test.go | 120 ++++++++++++++++++++++++ operator/duties/scheduler.go | 8 +- protocol/v2/ssv/runner/ptc_attester.go | 7 +- protocol/v2/types/gloas/beacon_vote.go | 3 + ssvsigner/ekm/local_key_manager.go | 12 ++- ssvsigner/ekm/remote_key_manager.go | 8 +- 10 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 operator/duties/ptc_attestation_test.go diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index a530b7356e..c33fed7155 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -118,6 +118,11 @@ type GoClient struct { clients []Client multiClient MultiClient + // clientAddresses holds each client's unmasked address for the hand-rolled PTC requests + // (ptc.go) — Client.Address() is log-masked and unusable for real requests. Drop when those + // endpoints become typed go-eth2-client calls. + clientAddresses map[Client]string + syncDistanceTolerance phase0.Slot // attestationReqInflight helps prevent duplicate attestation data requests @@ -208,6 +213,7 @@ func New(ctx context.Context, logger *zap.Logger, opt Options) (*GoClient, error proposalSoftTimeout: opt.ProposalSoftTimeout, supportedTopics: []eventTopic{eventTopicHead, eventTopicBlock}, activatedClients: hashmap.New[string, struct{}](), + clientAddresses: make(map[Client]string), } // First error stops the loop on purpose. addSingleClient sets WithAllowDelayedStart(true), so a valid @@ -348,7 +354,9 @@ func (gc *GoClient) addSingleClient(ctx context.Context, addr string) error { return fmt.Errorf("create http client: %w", err) } - gc.clients = append(gc.clients, httpClient.(*eth2clienthttp.Service)) + svc := httpClient.(*eth2clienthttp.Service) + gc.clients = append(gc.clients, svc) + gc.clientAddresses[svc] = addr return nil } diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 2fdb9a7104..bbe1479cc9 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -30,8 +30,9 @@ const ( ) // ptcHTTPClient issues the hand-rolled PTC requests; per-call deadlines come from the request -// context. It carries no operator transport config (TLS/auth) — acceptable for this interim -// surface, to be retired with the go-eth2-client rebase. +// context. Basic-auth embedded in the (unmasked) beacon address is applied by net/http; custom +// TLS/client-cert transport is not — acceptable for this interim surface, to be retired with the +// go-eth2-client rebase. var ptcHTTPClient = &http.Client{} // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch, from @@ -57,22 +58,22 @@ func (gc *GoClient) SubmitPayloadAttestationMessages(ctx context.Context, messag defer cancel() return gc.multiClientSubmit(ctx, "SubmitPayloadAttestationMessages", func(ctx context.Context, client Client) error { - return submitPayloadAttestationMessages(ctx, ptcHTTPClient, client.Address(), messages) + return submitPayloadAttestationMessages(ctx, ptcHTTPClient, gc.clientAddresses[client], messages) }) } -// firstClientResult runs fn against each beacon client in turn under the common timeout, -// returning the first success and recording every attempt; on all failures it joins the errors. +// firstClientResult runs fn against each beacon client in turn, each under its own common-timeout +// budget, returning the first success and recording every attempt; on all failures it joins the errors. func firstClientResult[T any](ctx context.Context, gc *GoClient, routeName, httpMethod string, fn func(ctx context.Context, addr string) (T, error)) (T, error) { - ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) - defer cancel() - var zero T var errs error for _, client := range gc.clients { + // Per-client timeout so a hung primary doesn't starve the fallbacks. + clientCtx, cancel := context.WithTimeout(ctx, gc.commonTimeout) start := time.Now() - res, err := fn(ctx, client.Address()) - recordRequest(ctx, gc.log, routeName, client, httpMethod, false, time.Since(start), err) + res, err := fn(clientCtx, gc.clientAddresses[client]) + recordRequest(clientCtx, gc.log, routeName, client, httpMethod, false, time.Since(start), err) + cancel() if err != nil { errs = errors.Join(errs, errSingleClient(err, client.Address(), routeName)) continue diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index f9546211f6..b625e0f2ab 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -60,6 +60,12 @@ func (b *Beacon) SlotStartTime(slot phase0.Slot) time.Time { return start } +// PayloadAttestationCutoff is the point 75% into the slot (PAYLOAD_ATTESTATION_DUE) at which a +// Gloas PTC member observes payload presence and runs its attestation. +func (b *Beacon) PayloadAttestationCutoff(slot phase0.Slot) time.Time { + return b.SlotStartTime(slot).Add(b.SlotDuration * 3 / 4) +} + // EstimatedCurrentSlot returns the estimation of the current slot func (b *Beacon) EstimatedCurrentSlot() phase0.Slot { return b.EstimatedSlotAtTime(time.Now()) diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go index af63d21ab5..e2fc306268 100644 --- a/operator/duties/ptc_attestation.go +++ b/operator/duties/ptc_attestation.go @@ -106,10 +106,9 @@ func (h *PTCAttestationHandler) fetchDuties(ctx context.Context, epoch phase0.Ep h.logger.Debug("fetched PTC duties", fields.Epoch(epoch), zap.Int("duties", len(ptcDuties))) } -// scheduleExecution fires the duty at the 75%-of-slot cutoff (PAYLOAD_ATTESTATION_DUE_BPS), with a -// deadline at slot end. +// scheduleExecution fires the duty at the payload-attestation cutoff, with a deadline at slot end. func (h *PTCAttestationHandler) scheduleExecution(ctx context.Context, slot phase0.Slot, duties []*spectypes.ValidatorDuty) { - executeAt := h.netCfg.SlotStartTime(slot).Add(h.netCfg.SlotDuration * 3 / 4) + executeAt := h.netCfg.PayloadAttestationCutoff(slot) deadline := h.netCfg.SlotStartTime(slot + 1) time.AfterFunc(time.Until(executeAt), func() { h.dutiesExecutor.ExecuteDuties(ctx, duties, deadline) diff --git a/operator/duties/ptc_attestation_test.go b/operator/duties/ptc_attestation_test.go new file mode 100644 index 0000000000..09cd6af026 --- /dev/null +++ b/operator/duties/ptc_attestation_test.go @@ -0,0 +1,120 @@ +package duties + +import ( + "context" + "testing" + "testing/synctest" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// captureExecutor records the duties handed to ExecuteDuties so a test can assert on them. +type captureExecutor struct { + executed chan []*spectypes.ValidatorDuty +} + +func (c *captureExecutor) ExecuteDuties(_ context.Context, duties []*spectypes.ValidatorDuty, _ time.Time) { + c.executed <- duties +} + +func (c *captureExecutor) ExecuteCommitteeDuties(context.Context, committeeDutiesMap, time.Time) {} + +// fetchDuties caches an epoch's duties on first fetch and short-circuits on repeat — the Times(1) +// expectations on both mocks fail if the second call re-fetches. +func TestPTCAttestationHandler_fetchDuties_cachesPerEpoch(t *testing.T) { + ctrl := gomock.NewController(t) + + epoch := phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + dutySlot := phase0.Slot(60) + pk := phase0.BLSPubKey{1, 2, 3} + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}). + Times(1) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().PayloadAttestationDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). + Return([]*gloas.PTCDuty{{PubKey: pk, ValidatorIndex: idx, Slot: dutySlot}}, nil). + Times(1) + + h := NewPTCAttestationHandler() + h.logger = zap.NewNop() + h.validatorProvider = vp + h.beaconNode = bn + + h.fetchDuties(context.Background(), epoch) + h.fetchDuties(context.Background(), epoch) + + require.Contains(t, h.duties, epoch) + require.Len(t, h.duties[epoch][dutySlot], 1) + require.Equal(t, spectypes.BNRolePTCAttester, h.duties[epoch][dutySlot][0].Type) + require.Equal(t, idx, h.duties[epoch][dutySlot][0].ValidatorIndex) +} + +// evictOutdated drops only epochs strictly before the current one. +func TestPTCAttestationHandler_evictOutdated(t *testing.T) { + h := NewPTCAttestationHandler() + for _, e := range []phase0.Epoch{4, 5, 6} { + h.duties[e] = map[phase0.Slot][]*spectypes.ValidatorDuty{} + } + + h.evictOutdated(5) + + require.NotContains(t, h.duties, phase0.Epoch(4)) + require.Contains(t, h.duties, phase0.Epoch(5)) + require.Contains(t, h.duties, phase0.Epoch(6)) +} + +// scheduleExecution fires the duty at the 75%-of-slot cutoff, not before. +func TestPTCAttestationHandler_scheduleExecution_firesAtCutoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + beaconCfg := *networkconfig.TestNetwork.Beacon + beaconCfg.GenesisTime = time.Now() + beaconCfg.SlotDuration = time.Second + beaconCfg.SlotsPerEpoch = testSlotsPerEpoch + netCfg := *networkconfig.TestNetwork + netCfg.Beacon = &beaconCfg + + executed := make(chan []*spectypes.ValidatorDuty, 1) + h := NewPTCAttestationHandler() + h.logger = zap.NewNop() + h.netCfg = &netCfg + h.dutiesExecutor = &captureExecutor{executed: executed} + + slot := phase0.Slot(3) + duties := []*spectypes.ValidatorDuty{{Type: spectypes.BNRolePTCAttester, Slot: slot}} + h.scheduleExecution(context.Background(), slot, duties) + + cutoff := netCfg.PayloadAttestationCutoff(slot) + + // Just shy of the cutoff: nothing executed yet. + time.Sleep(time.Until(cutoff) - time.Millisecond) + synctest.Wait() + select { + case <-executed: + t.Fatal("duty executed before the 75% cutoff") + default: + } + + // Crossing the cutoff triggers execution with the scheduled duties. + time.Sleep(2 * time.Millisecond) + synctest.Wait() + select { + case got := <-executed: + require.Equal(t, duties, got) + default: + t.Fatal("duty not executed at the cutoff") + } + }) +} diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index 73d1b39f67..4a9308ee85 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -481,7 +481,13 @@ func (s *Scheduler) ExecuteDuties(ctx context.Context, duties []*spectypes.Valid logger.Debug(eventMsg) span.AddEvent(eventMsg) - slotDelay := time.Since(s.netCfg.SlotStartTime(duty.Slot)) + // PTC duties fire at the payload-attestation cutoff by design, so measure lateness from + // there, not slot start, to avoid a false "late execution" warning. + expectedStart := s.netCfg.SlotStartTime(duty.Slot) + if role == spectypes.RolePTCAttester { + expectedStart = s.netCfg.PayloadAttestationCutoff(duty.Slot) + } + slotDelay := time.Since(expectedStart) // For roles where duty.Slot is a shared coordination point rather // than the execution target (see dutySlotIsExecutionSlot), slotDelay diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index cadcbec318..5f9b8d4ad3 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -69,6 +69,9 @@ func (r *PTCAttesterRunner) StartNewDuty(ctx context.Context, logger *zap.Logger if err != nil { return err } + // Clear any prior observation; executeDuty re-freezes it only if this operator attests, so an + // abstained or not-yet-executed duty stays nil. + r.payloadAttestationData = nil return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) } @@ -146,7 +149,9 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, // operator that has seen no beacon block for the slot abstains (signs and submits nothing). data, err := r.beacon.PayloadAttestationData(ctx, slot) if err != nil { - logger.Debug("abstaining from PTC attestation: no payload attestation data", fields.Slot(slot), zap.Error(err)) + // A beacon-node failure (syncing, auth, unreachable) forces an abstain but is operational, + // not the normal "saw no block" case below — surface it so it isn't silently dropped. + logger.Warn("abstaining from PTC attestation: failed to fetch payload attestation data", fields.Slot(slot), zap.Error(err)) r.markDutySucceeded() return nil } diff --git a/protocol/v2/types/gloas/beacon_vote.go b/protocol/v2/types/gloas/beacon_vote.go index 8ef11af848..1b24fefc26 100644 --- a/protocol/v2/types/gloas/beacon_vote.go +++ b/protocol/v2/types/gloas/beacon_vote.go @@ -13,6 +13,9 @@ import ( // Source/Target checkpoints, 112 bytes) and appends the BN-supplied // AttestationData.Index for a fixed 120-byte SSZ encoding (field order per SIP #94). // The 120B-vs-112B length difference makes a cross-fork decode fail cleanly. +// +// Not yet wired: this is the foundation for the Gloas committee runner (the §2 attestation track); +// the PTC slice doesn't use it. Tracked so it isn't mistaken for dead code. type GloasBeaconVote struct { BlockRoot phase0.Root `ssz-size:"32"` Source *phase0.Checkpoint diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index 41412e026e..dab908879b 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -247,13 +247,21 @@ func (km *LocalKeyManager) signBeaconObject( case spectypes.DomainPTCAttester: // Gloas (ePBS) PTC payload attestation: a plain BLS signature over the SSZ root under // DomainPTCAttester, with no slashing protection (it is not in the slashing predicate). - // SignAggregateAndProof is the generic ssz.HashRoot signer; reuse it. - return km.signer.SignAggregateAndProof(obj, domain, pubKey[:]) + return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") } } +// signSSZRoot BLS-signs obj's SSZ signing root under domain, with no slashing protection. The +// underlying signer exposes only typed methods; SignAggregateAndProof is its generic ssz.HashRoot +// signer (it hashes any object), so it backs this until eth2-key-manager grows a dedicated root +// signer. +// TODO(gloas): swap to a purpose-named eth2-key-manager root signer once one exists. +func signSSZRoot(s signer.ValidatorSigner, obj ssz.HashRoot, domain phase0.Domain, pubKey []byte) ([]byte, []byte, error) { + return s.SignAggregateAndProof(obj, domain, pubKey) +} + func (km *LocalKeyManager) IsAttestationSlashable(pubKey phase0.BLSPubKey, attData *phase0.AttestationData) error { return km.slashingProtector.IsAttestationSlashable(pubKey, attData) } diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index 1b98a48484..4afb0527bb 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -403,9 +403,11 @@ func (km *RemoteKeyManager) prepareSignRequest( req.Type = web3signer.TypeValidatorRegistration req.ValidatorRegistration = data case spectypes.DomainPTCAttester: - // Gloas (ePBS) PTC payload attestations have no Web3Signer request type, so the remote - // signer cannot sign them until Web3Signer adds support; PTC requires local signing. - return web3signer.SignRequest{}, phase0.Root{}, errors.New("payload attestation signing is not supported by the remote signer") + // Gloas (ePBS) PTC payload attestations have no Web3Signer request type, so a remote-signing + // operator can't participate in PTC. Bounded — the cluster reconstructs while ≤ f operators + // are remote-signing — but those operators must sign PTC-assigned validators locally. + // TODO(gloas): route PTC signing through Web3Signer once it adds a payload-attestation type. + return web3signer.SignRequest{}, phase0.Root{}, errors.New("payload attestation signing is not supported by the remote signer: Web3Signer has no payload-attestation type, use local signing for PTC-assigned validators") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") } From c1f7f5c1bb84056db266098c5340bd58b46044c8 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 15:30:47 +0300 Subject: [PATCH 017/150] ptc: reconcile runner with boole-fork's renamed duty-completion API boole-fork (#2899) replaced BaseRunner.finishDuty / ErrRunningDutyFinished with markDutySucceeded / markDutyNotRequired / markDutyFailed + ErrRunningDutySucceeded. Adapt PTCAttesterRunner accordingly: - pre-consensus success (reconstructed + submitted) -> markDutySucceeded - abstain on no block seen (legitimately nothing to submit) -> markDutyNotRequired - BN-fetch failure (operational) -> markDutyFailed, so it surfaces in duty metrics - post-quorum terminal failure -> markDutyFailed via a deferred guard (named return) executeDuty's own errors are already marked failed by baseStartNewNonBeaconDuty. --- protocol/v2/ssv/runner/ptc_attester.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index 5f9b8d4ad3..5f0d308ccc 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -75,10 +75,10 @@ func (r *PTCAttesterRunner) StartNewDuty(ctx context.Context, logger *zap.Logger return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) } -func (r *PTCAttesterRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { +func (r *PTCAttesterRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { hasQuorum, roots, err := r.basePreConsensusMsgProcessing(ctx, logger, r, signedMsg) if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { - // The runner is reused across duties, so a late message for a finished duty is retryable. + // The runner is reused across duties, so a late message for a concluded duty is retryable. err = NewRetryableError(err) } if err != nil { @@ -90,6 +90,14 @@ func (r *PTCAttesterRunner) ProcessPreConsensus(ctx context.Context, logger *zap return nil } + // We have quorum and are committed to completing this duty here; the quorum fires only once, + // so a terminal failure below won't be retried. + defer func() { + if err != nil { + r.markDutyFailed(err) + } + }() + if r.payloadAttestationData == nil { return fmt.Errorf("reached quorum without a frozen payload attestation data") } @@ -149,15 +157,15 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, // operator that has seen no beacon block for the slot abstains (signs and submits nothing). data, err := r.beacon.PayloadAttestationData(ctx, slot) if err != nil { - // A beacon-node failure (syncing, auth, unreachable) forces an abstain but is operational, - // not the normal "saw no block" case below — surface it so it isn't silently dropped. - logger.Warn("abstaining from PTC attestation: failed to fetch payload attestation data", fields.Slot(slot), zap.Error(err)) - r.markDutySucceeded() + // A beacon-node failure (syncing, auth, unreachable) is operational, not the "saw no block" + // abstain below — record it as a failed duty so BN-induced PTC losses surface in metrics. + logger.Warn("PTC attestation failed: could not fetch payload attestation data", fields.Slot(slot), zap.Error(err)) + r.markDutyFailed(err) return nil } if data.BeaconBlockRoot == (phase0.Root{}) { logger.Debug("abstaining from PTC attestation: no beacon block for slot", fields.Slot(slot)) - r.markDutySucceeded() + r.markDutyNotRequired() return nil } From 746259e3e3160e4539abedf5925c5aa5386de84c Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 17:42:49 +0300 Subject: [PATCH 018/150] gloas: add ProposerPreferences wire types, endpoint abstraction, signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the SIP #94 §5 ProposerPreferences duty (the Gloas replacement for validator registration): - protocol/v2/types/gloas: ProposerPreferences (76B SSZ) + SignedProposerPreferences (172B), byte-compatible with the consensus-specs container; SSZ + beacon-API JSON + round-trip/wire-form tests - beacon: ProposerPreferencesCalls.SubmitProposerPreferences abstraction; the goclient impl is a stub (beacon-APIs exposes no publication endpoint yet, SIP #94 §5) - ekm: sign DomainProposerPreferences locally via the generic root signer; remote (Web3Signer) rejects it (no request type), as for PTC --- beacon/goclient/proposer_preferences.go | 15 ++ protocol/v2/blockchain/beacon/client.go | 9 + protocol/v2/blockchain/beacon/mock_client.go | 52 +++++ .../v2/types/gloas/proposer_preferences.go | 115 +++++++++++ .../gloas/proposer_preferences_encoding.go | 181 ++++++++++++++++++ .../types/gloas/proposer_preferences_test.go | 103 ++++++++++ ssvsigner/ekm/local_key_manager.go | 4 + ssvsigner/ekm/remote_key_manager.go | 7 + 8 files changed, 486 insertions(+) create mode 100644 beacon/goclient/proposer_preferences.go create mode 100644 protocol/v2/types/gloas/proposer_preferences.go create mode 100644 protocol/v2/types/gloas/proposer_preferences_encoding.go create mode 100644 protocol/v2/types/gloas/proposer_preferences_test.go diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go new file mode 100644 index 0000000000..9c984da006 --- /dev/null +++ b/beacon/goclient/proposer_preferences.go @@ -0,0 +1,15 @@ +package goclient + +import ( + "context" + "errors" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5). +// beacon-APIs exposes no validator-facing publication endpoint yet (the BN shape is TBD), so this is +// a stub that errors rather than silently dropping them; swap in a real client once it lands. +func (*GoClient) SubmitProposerPreferences(_ context.Context, _ []*gloas.SignedProposerPreferences) error { + return errors.New("submit proposer preferences: no beacon-API publication endpoint available upstream yet (SIP #94 §5)") +} diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index cd6a0fcf17..a2dd11ea93 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -128,6 +128,7 @@ type BeaconNode interface { ValidatorRegistrationCalls VoluntaryExitCalls PTCCalls + ProposerPreferencesCalls DomainCalls beaconDuties @@ -147,3 +148,11 @@ type PTCCalls interface { // SubmitPayloadAttestationMessages submits signed PTC messages to the beacon node's pool. SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error } + +// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences. +// Publication has no beacon-API endpoint upstream yet (SIP #94 §5), so SubmitProposerPreferences is +// currently a stub (see beacon/goclient/proposer_preferences.go). +type ProposerPreferencesCalls interface { + // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. + SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error +} diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index 18c51ec43e..2a45ab6836 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -1032,6 +1032,20 @@ func (mr *MockBeaconNodeMockRecorder) SubmitProposalPreparations(ctx, preparatio return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposalPreparations", reflect.TypeOf((*MockBeaconNode)(nil).SubmitProposalPreparations), ctx, preparations) } +// SubmitProposerPreferences mocks base method. +func (m *MockBeaconNode) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitProposerPreferences", ctx, preferences) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitProposerPreferences indicates an expected call of SubmitProposerPreferences. +func (mr *MockBeaconNodeMockRecorder) SubmitProposerPreferences(ctx, preferences any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposerPreferences", reflect.TypeOf((*MockBeaconNode)(nil).SubmitProposerPreferences), ctx, preferences) +} + // SubmitSignedAggregateSelectionProof mocks base method. func (m *MockBeaconNode) SubmitSignedAggregateSelectionProof(ctx context.Context, msg *spec.VersionedSignedAggregateAndProof) error { m.ctrl.T.Helper() @@ -1226,3 +1240,41 @@ func (mr *MockPTCCallsMockRecorder) SubmitPayloadAttestationMessages(ctx, messag mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitPayloadAttestationMessages", reflect.TypeOf((*MockPTCCalls)(nil).SubmitPayloadAttestationMessages), ctx, messages) } + +// MockProposerPreferencesCalls is a mock of ProposerPreferencesCalls interface. +type MockProposerPreferencesCalls struct { + ctrl *gomock.Controller + recorder *MockProposerPreferencesCallsMockRecorder + isgomock struct{} +} + +// MockProposerPreferencesCallsMockRecorder is the mock recorder for MockProposerPreferencesCalls. +type MockProposerPreferencesCallsMockRecorder struct { + mock *MockProposerPreferencesCalls +} + +// NewMockProposerPreferencesCalls creates a new mock instance. +func NewMockProposerPreferencesCalls(ctrl *gomock.Controller) *MockProposerPreferencesCalls { + mock := &MockProposerPreferencesCalls{ctrl: ctrl} + mock.recorder = &MockProposerPreferencesCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProposerPreferencesCalls) EXPECT() *MockProposerPreferencesCallsMockRecorder { + return m.recorder +} + +// SubmitProposerPreferences mocks base method. +func (m *MockProposerPreferencesCalls) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitProposerPreferences", ctx, preferences) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitProposerPreferences indicates an expected call of SubmitProposerPreferences. +func (mr *MockProposerPreferencesCallsMockRecorder) SubmitProposerPreferences(ctx, preferences any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposerPreferences", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).SubmitProposerPreferences), ctx, preferences) +} diff --git a/protocol/v2/types/gloas/proposer_preferences.go b/protocol/v2/types/gloas/proposer_preferences.go new file mode 100644 index 0000000000..31b50f0e1b --- /dev/null +++ b/protocol/v2/types/gloas/proposer_preferences.go @@ -0,0 +1,115 @@ +package gloas + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. The phase0/bellatrix --include is resolved from the module +// graph (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./proposer_preferences.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs ProposerPreferences,SignedProposerPreferences" + +// ProposerPreferences is the Gloas (ePBS) preference a proposer broadcasts for an upcoming +// proposal slot (SIP #94 §5): the fee recipient and target gas limit builders must honor, pinned to +// the proposer-lookahead seed via DependentRoot. Signed under DomainProposerPreferences with domain +// epoch = epoch(ProposalSlot). Fixed 76-byte SSZ. +type ProposerPreferences struct { + DependentRoot phase0.Root `ssz-size:"32"` + ProposalSlot phase0.Slot + ValidatorIndex phase0.ValidatorIndex + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + TargetGasLimit uint64 +} + +// SignedProposerPreferences is a ProposerPreferences plus the validator's signature, broadcast on +// the proposer_preferences gossip topic. Fixed 172-byte SSZ. +type SignedProposerPreferences struct { + Message *ProposerPreferences + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// proposerPreferencesJSON is the beacon-API JSON form: uint64 as a decimal string, root/address as +// 0x-hex, per go-eth2-client conventions. +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 fmt.Errorf("invalid JSON: %w", err) + } + if err := decodeHexInto(p.DependentRoot[:], data.DependentRoot, "dependent root"); err != nil { + return err + } + proposalSlot, err := strconv.ParseUint(data.ProposalSlot, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for proposal slot: %w", err) + } + p.ProposalSlot = phase0.Slot(proposalSlot) + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for validator index: %w", err) + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + if err := decodeHexInto(p.FeeRecipient[:], data.FeeRecipient, "fee recipient"); err != nil { + return err + } + targetGasLimit, err := strconv.ParseUint(data.TargetGasLimit, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for target gas limit: %w", err) + } + p.TargetGasLimit = targetGasLimit + return nil +} + +// signedProposerPreferencesJSON is the beacon-API JSON form of SignedProposerPreferences. +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 fmt.Errorf("invalid JSON: %w", err) + } + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + if err := decodeHexInto(s.Signature[:], data.Signature, "signature"); err != nil { + return err + } + return nil +} diff --git a/protocol/v2/types/gloas/proposer_preferences_encoding.go b/protocol/v2/types/gloas/proposer_preferences_encoding.go new file mode 100644 index 0000000000..bb4f412e90 --- /dev/null +++ b/protocol/v2/types/gloas/proposer_preferences_encoding.go @@ -0,0 +1,181 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: d998a7b21a5ca8a83f980a2b202c32bad3a5fb6b7a089127fb17f94628fe5954 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the ProposerPreferences object +func (p *ProposerPreferences) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(p) +} + +// MarshalSSZTo ssz marshals the ProposerPreferences object to a target array +func (p *ProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'DependentRoot' + dst = append(dst, p.DependentRoot[:]...) + + // Field (1) 'ProposalSlot' + dst = ssz.MarshalUint64(dst, uint64(p.ProposalSlot)) + + // Field (2) 'ValidatorIndex' + dst = ssz.MarshalUint64(dst, uint64(p.ValidatorIndex)) + + // Field (3) 'FeeRecipient' + dst = append(dst, p.FeeRecipient[:]...) + + // Field (4) 'TargetGasLimit' + dst = ssz.MarshalUint64(dst, p.TargetGasLimit) + + return +} + +// UnmarshalSSZ ssz unmarshals the ProposerPreferences object +func (p *ProposerPreferences) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 76 { + return ssz.ErrSize + } + + // Field (0) 'DependentRoot' + copy(p.DependentRoot[:], buf[0:32]) + + // Field (1) 'ProposalSlot' + p.ProposalSlot = phase0.Slot(ssz.UnmarshallUint64(buf[32:40])) + + // Field (2) 'ValidatorIndex' + p.ValidatorIndex = phase0.ValidatorIndex(ssz.UnmarshallUint64(buf[40:48])) + + // Field (3) 'FeeRecipient' + copy(p.FeeRecipient[:], buf[48:68]) + + // Field (4) 'TargetGasLimit' + p.TargetGasLimit = ssz.UnmarshallUint64(buf[68:76]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the ProposerPreferences object +func (p *ProposerPreferences) SizeSSZ() (size int) { + size = 76 + return +} + +// HashTreeRoot ssz hashes the ProposerPreferences object +func (p *ProposerPreferences) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(p) +} + +// HashTreeRootWith ssz hashes the ProposerPreferences object with a hasher +func (p *ProposerPreferences) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'DependentRoot' + hh.PutBytes(p.DependentRoot[:]) + + // Field (1) 'ProposalSlot' + hh.PutUint64(uint64(p.ProposalSlot)) + + // Field (2) 'ValidatorIndex' + hh.PutUint64(uint64(p.ValidatorIndex)) + + // Field (3) 'FeeRecipient' + hh.PutBytes(p.FeeRecipient[:]) + + // Field (4) 'TargetGasLimit' + hh.PutUint64(p.TargetGasLimit) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the ProposerPreferences object +func (p *ProposerPreferences) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(p) +} + +// MarshalSSZ ssz marshals the SignedProposerPreferences object +func (s *SignedProposerPreferences) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedProposerPreferences object to a target array +func (s *SignedProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(ProposerPreferences) + } + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedProposerPreferences object +func (s *SignedProposerPreferences) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 172 { + return ssz.ErrSize + } + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(ProposerPreferences) + } + if err = s.Message.UnmarshalSSZ(buf[0:76]); err != nil { + return err + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[76:172]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedProposerPreferences object +func (s *SignedProposerPreferences) SizeSSZ() (size int) { + size = 172 + return +} + +// HashTreeRoot ssz hashes the SignedProposerPreferences object +func (s *SignedProposerPreferences) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedProposerPreferences object with a hasher +func (s *SignedProposerPreferences) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(ProposerPreferences) + } + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedProposerPreferences object +func (s *SignedProposerPreferences) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/proposer_preferences_test.go b/protocol/v2/types/gloas/proposer_preferences_test.go new file mode 100644 index 0000000000..2bdd92ec88 --- /dev/null +++ b/protocol/v2/types/gloas/proposer_preferences_test.go @@ -0,0 +1,103 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestProposerPreferences_SSZ(t *testing.T) { + p := &ProposerPreferences{ + DependentRoot: phase0.Root{0x01, 0x02}, + ProposalSlot: 42, + ValidatorIndex: 7, + FeeRecipient: bellatrix.ExecutionAddress{0xaa, 0xbb}, + TargetGasLimit: 36_000_000, + } + require.Equal(t, 76, p.SizeSSZ()) + + b, err := p.MarshalSSZ() + require.NoError(t, err) + require.Len(t, b, 76) + + var dec ProposerPreferences + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, p, &dec) + + htr1, err := p.HashTreeRoot() + require.NoError(t, err) + htr2, err := dec.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, htr1, htr2) +} + +func TestSignedProposerPreferences_SSZ(t *testing.T) { + s := &SignedProposerPreferences{ + Message: &ProposerPreferences{ + DependentRoot: phase0.Root{0xaa}, + ProposalSlot: 9, + ValidatorIndex: 3, + FeeRecipient: bellatrix.ExecutionAddress{0x11}, + TargetGasLimit: 30_000_000, + }, + Signature: phase0.BLSSignature{0xbb, 0xcc}, + } + require.Equal(t, 172, s.SizeSSZ()) + + b, err := s.MarshalSSZ() + require.NoError(t, err) + require.Len(t, b, 172) + + var dec SignedProposerPreferences + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, s, &dec) + + _, err = s.HashTreeRoot() + require.NoError(t, err) +} + +func TestProposerPreferences_JSON(t *testing.T) { + p := &ProposerPreferences{ + DependentRoot: phase0.Root{0x01, 0x02}, + ProposalSlot: 42, + ValidatorIndex: 7, + FeeRecipient: bellatrix.ExecutionAddress{0xaa, 0xbb}, + TargetGasLimit: 36_000_000, + } + b, err := json.Marshal(p) + require.NoError(t, err) + // Lock the beacon-API wire form: snake_case keys, uint64 as decimal strings, root/address 0x-hex. + require.JSONEq(t, fmt.Sprintf( + `{"dependent_root":"%#x","proposal_slot":"42","validator_index":"7","fee_recipient":"%#x","target_gas_limit":"36000000"}`, + p.DependentRoot, p.FeeRecipient), string(b)) + + var dec ProposerPreferences + require.NoError(t, json.Unmarshal(b, &dec)) + require.Equal(t, p, &dec) +} + +func TestSignedProposerPreferences_JSON(t *testing.T) { + s := &SignedProposerPreferences{ + Message: &ProposerPreferences{ + DependentRoot: phase0.Root{0xaa}, + ProposalSlot: 9, + ValidatorIndex: 3, + FeeRecipient: bellatrix.ExecutionAddress{0x11}, + TargetGasLimit: 30_000_000, + }, + Signature: phase0.BLSSignature{0xbb, 0xcc}, + } + b, err := json.Marshal(s) + require.NoError(t, err) + msgJSON, err := json.Marshal(s.Message) + require.NoError(t, err) + require.JSONEq(t, fmt.Sprintf(`{"message":%s,"signature":"%#x"}`, msgJSON, s.Signature), string(b)) + + var dec SignedProposerPreferences + require.NoError(t, json.Unmarshal(b, &dec)) + require.Equal(t, s, &dec) +} diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index dab908879b..dc04d97f1c 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -248,6 +248,10 @@ func (km *LocalKeyManager) signBeaconObject( // Gloas (ePBS) PTC payload attestation: a plain BLS signature over the SSZ root under // DomainPTCAttester, with no slashing protection (it is not in the slashing predicate). return signSSZRoot(km.signer, obj, domain, pubKey[:]) + case spectypes.DomainProposerPreferences: + // Gloas (ePBS) proposer preferences: a plain BLS signature over the SSZ root under + // DomainProposerPreferences, with no slashing protection. + return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") } diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index 4afb0527bb..cebf77481d 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -408,6 +408,13 @@ func (km *RemoteKeyManager) prepareSignRequest( // are remote-signing — but those operators must sign PTC-assigned validators locally. // TODO(gloas): route PTC signing through Web3Signer once it adds a payload-attestation type. return web3signer.SignRequest{}, phase0.Root{}, errors.New("payload attestation signing is not supported by the remote signer: Web3Signer has no payload-attestation type, use local signing for PTC-assigned validators") + case spectypes.DomainProposerPreferences: + // Gloas (ePBS) proposer preferences have no Web3Signer request type, so a remote-signing + // operator can't sign them — note this replaces the Web3Signer-supported ValidatorRegistration + // at the Gloas fork. Bounded (cluster reconstructs while ≤ f operators are remote-signing), but + // those operators must sign locally. + // TODO(gloas): route proposer-preferences signing through Web3Signer once it adds the type. + return web3signer.SignRequest{}, phase0.Root{}, errors.New("proposer preferences signing is not supported by the remote signer: Web3Signer has no proposer-preferences type, use local signing") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") } From 296a098160c219b93db4a4a5d7c686b47a0100fc Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 20:47:41 +0300 Subject: [PATCH 019/150] =?UTF-8?q?gloas:=20add=20ProposerPreferences=20ru?= =?UTF-8?q?nner=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validator-scoped, non-QBFT runner for the proposer-preferences duty: the honest-convergence shape of the PTC runner plus validator-registration's fee-recipient / gas-limit derive. One duty per upcoming proposal slot — each operator builds the preference from its own beacon node, freezes it, signs under DOMAIN_PROPOSER_PREFERENCES (domain epoch = epoch(proposal_slot)), and a per-validator signature reconstructs only over byte-identical preferences. - duty.Slot = msg.Slot = proposal_slot, no emission-slot override: the base runner keys validatePartialSigMsg, verifyExpectedRoot's domain epoch, and messageEarliness off DutySlot, and the wire signature is under epoch(proposal_slot). Permitting the future slot is message validation's job (a role-specific earliness allowance), added with T9. - dependent_root: raw-HTTP GET /eth/v2/validator/duties/proposer/{epoch} for the proposal slot's epoch (go-eth2-client drops the field), fetched + frozen per-operator so convergence is over identical roots. - ErrProposerPreferencesPublishUnavailable sentinel: no validator-facing publication endpoint exists upstream yet, so SubmitProposerPreferences returns it and the runner records not-required rather than failed. - dutySlotIsExecutionSlot(RoleProposerPreferences)=false: duty.Slot is a future coordination point, so the scheduler's lateness check is skipped. Not yet wired into SetupRunners or message validation; inert until those land. --- beacon/goclient/proposer_preferences.go | 41 ++- operator/duties/observability.go | 16 +- protocol/v2/blockchain/beacon/client.go | 8 +- protocol/v2/blockchain/beacon/mock_client.go | 30 ++ .../v2/ssv/runner/proposer_preferences.go | 288 ++++++++++++++++++ .../v2/types/gloas/proposer_preferences.go | 7 + 6 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 protocol/v2/ssv/runner/proposer_preferences.go diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 9c984da006..920d281270 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -2,14 +2,47 @@ package goclient import ( "context" - "errors" + "encoding/hex" + "fmt" + "net/http" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) +// ProposerDutiesDependentRoot returns the dependent root from the v2 proposer-duties response for the +// given epoch — the proposer-lookahead seed a preference is pinned to (SIP #94 §5; callers pass the +// proposal slot's epoch). Gloas's v2 endpoint computes this root under the new proposer-lookahead +// seed; go-eth2-client drops the field, so this is a raw-HTTP fetch (the same interim surface as the +// PTC endpoints) until the fork exposes it. +func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + return firstClientResult(ctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { + var resp struct { + DependentRoot string `json:"dependent_root"` + } + url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) + if err := ptcDo(ctx, ptcHTTPClient, http.MethodGet, url, nil, nil, &resp); err != nil { + return phase0.Root{}, err + } + raw, err := hex.DecodeString(strings.TrimPrefix(resp.DependentRoot, "0x")) + if err != nil { + return phase0.Root{}, fmt.Errorf("decode dependent_root %q: %w", resp.DependentRoot, err) + } + var root phase0.Root + if len(raw) != len(root) { + return phase0.Root{}, fmt.Errorf("dependent_root: expected %d bytes, got %d", len(root), len(raw)) + } + copy(root[:], raw) + return root, nil + }) +} + // SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5). -// beacon-APIs exposes no validator-facing publication endpoint yet (the BN shape is TBD), so this is -// a stub that errors rather than silently dropping them; swap in a real client once it lands. +// beacon-APIs exposes no validator-facing publication endpoint yet (the BN shape is TBD), so this +// returns the gloas.ErrProposerPreferencesPublishUnavailable sentinel — which the runner treats as a +// benign no-op — rather than silently dropping them; swap in a real client once the endpoint lands. func (*GoClient) SubmitProposerPreferences(_ context.Context, _ []*gloas.SignedProposerPreferences) error { - return errors.New("submit proposer preferences: no beacon-API publication endpoint available upstream yet (SIP #94 §5)") + return gloas.ErrProposerPreferencesPublishUnavailable } diff --git a/operator/duties/observability.go b/operator/duties/observability.go index c999d14644..2541ac3aa7 100644 --- a/operator/duties/observability.go +++ b/operator/duties/observability.go @@ -51,15 +51,21 @@ func recordDutyScheduled(ctx context.Context, role types.RunnerRole, slotDelay t // dutySlotIsExecutionSlot reports whether duty.Slot for the given role // represents the wall-clock slot at which this operator intends to execute // its duty. True for most roles (attester, proposer, etc.); false for roles -// where duty.Slot is a shared coordination point intentionally held in the -// past (the operator executes later than duty.Slot) — see -// voluntaryExitDutySlotsToPostpone and validatorRegistrationDutySlotsToPostpone -// for the canonical rationale. +// where duty.Slot is a shared coordination point that does not coincide with +// execution, so measuring slotDelay against it is meaningless and the lateness +// check is skipped. This happens in either direction: +// - voluntary-exit and validator-registration hold duty.Slot in the past and +// execute later (see voluntaryExitDutySlotsToPostpone and +// validatorRegistrationDutySlotsToPostpone for the canonical rationale); +// - proposer-preferences sets duty.Slot to the future proposal slot the +// preference targets and emits earlier, near the current slot. // // For validator-registration this returns false for both the event-driven // path (where the deferred-broadcast trade-off applies) and the periodic // path (where slotDelay would be ~0 anyway) — keeping the role-level check // simple is preferable to distinguishing the two paths in the duty. func dutySlotIsExecutionSlot(role types.RunnerRole) bool { - return role != types.RoleVoluntaryExit && role != types.RoleValidatorRegistration + return role != types.RoleVoluntaryExit && + role != types.RoleValidatorRegistration && + role != types.RoleProposerPreferences } diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index a2dd11ea93..c4380ba4dc 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -150,9 +150,13 @@ type PTCCalls interface { } // ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences. -// Publication has no beacon-API endpoint upstream yet (SIP #94 §5), so SubmitProposerPreferences is -// currently a stub (see beacon/goclient/proposer_preferences.go). +// Publication has no beacon-API endpoint upstream yet (SIP #94 §5), so SubmitProposerPreferences +// returns the gloas.ErrProposerPreferencesPublishUnavailable sentinel for now (see +// beacon/goclient/proposer_preferences.go). type ProposerPreferencesCalls interface { + // ProposerDutiesDependentRoot returns the proposer-duties dependent root for the epoch — the + // seed the proposer-lookahead is pinned to. go-eth2-client drops it, so it's fetched via raw HTTP. + ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error } diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index 2a45ab6836..9f113c81c2 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -934,6 +934,21 @@ func (mr *MockBeaconNodeMockRecorder) ProposerDuties(ctx, epoch, validatorIndice return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDuties", reflect.TypeOf((*MockBeaconNode)(nil).ProposerDuties), ctx, epoch, validatorIndices) } +// ProposerDutiesDependentRoot mocks base method. +func (m *MockBeaconNode) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProposerDutiesDependentRoot", ctx, epoch) + ret0, _ := ret[0].(phase0.Root) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ProposerDutiesDependentRoot indicates an expected call of ProposerDutiesDependentRoot. +func (mr *MockBeaconNodeMockRecorder) ProposerDutiesDependentRoot(ctx, epoch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockBeaconNode)(nil).ProposerDutiesDependentRoot), ctx, epoch) +} + // SetProposalPreparationsProvider mocks base method. func (m *MockBeaconNode) SetProposalPreparationsProvider(provider func() ([]*v1.ProposalPreparation, error)) { m.ctrl.T.Helper() @@ -1265,6 +1280,21 @@ func (m *MockProposerPreferencesCalls) EXPECT() *MockProposerPreferencesCallsMoc return m.recorder } +// ProposerDutiesDependentRoot mocks base method. +func (m *MockProposerPreferencesCalls) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProposerDutiesDependentRoot", ctx, epoch) + ret0, _ := ret[0].(phase0.Root) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ProposerDutiesDependentRoot indicates an expected call of ProposerDutiesDependentRoot. +func (mr *MockProposerPreferencesCallsMockRecorder) ProposerDutiesDependentRoot(ctx, epoch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).ProposerDutiesDependentRoot), ctx, epoch) +} + // SubmitProposerPreferences mocks base method. func (m *MockProposerPreferencesCalls) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { m.ctrl.T.Helper() diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go new file mode 100644 index 0000000000..0ee725e828 --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -0,0 +1,288 @@ +package runner + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + spectypes "github.com/ssvlabs/ssv-spec/types" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/observability/log/fields" + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" + ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" +) + +var _ Runner = (*ProposerPreferencesRunner)(nil) + +// ProposerPreferencesRunner runs the Gloas (ePBS) proposer-preferences duty (SIP #94 §5): one duty +// per upcoming proposal slot, broadcasting the fee recipient and target gas limit builders must +// honor. Like the PTC runner it has no consensus or pre-consensus negotiation — each operator builds +// the preference from its own beacon node and a per-validator signature reconstructs only once a +// threshold of operators converged on byte-identical preferences (honest convergence, not consensus). +// +// duty.Slot is the proposal slot the preference targets, which is also the slot carried on the +// partial-signature message and the slot whose epoch fixes the signing domain — keeping all three in +// step with the base runner's slot checks. The duty executes (emits) earlier, near the current slot, +// so the message rides a future slot; permitting that is the message-validation layer's job, not the +// runner's. +type ProposerPreferencesRunner struct { + *BaseRunner + + beacon beacon.BeaconNode + network protocolp2p.Network + signer ekm.BeaconSigner + operatorSigner ssvtypes.OperatorSigner + feeRecipientProvider feeRecipientProvider + gasLimit uint64 + + // proposerPreferences is the operator's frozen observation for this proposal slot: the preference + // it built (including the dependent_root its own beacon node reported). Incoming partial signatures + // are validated and aggregated against exactly this object's signing root; nil means the duty has + // not executed yet. + proposerPreferences *gloas.ProposerPreferences +} + +// ProposerPreferencesRunnerOptions bundles the dependencies required by NewProposerPreferencesRunner. +type ProposerPreferencesRunnerOptions struct { + BaseRunnerOptions + + FeeRecipientProvider feeRecipientProvider + GasLimit uint64 +} + +func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner, error) { + if len(opts.Share) != 1 { + return nil, fmt.Errorf("must have one share") + } + + return &ProposerPreferencesRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleProposerPreferences, + NetworkConfig: opts.NetworkConfig, + Share: opts.Share, + }, + + beacon: opts.Beacon, + network: opts.Network, + signer: opts.Signer, + operatorSigner: opts.OperatorSigner, + feeRecipientProvider: opts.FeeRecipientProvider, + gasLimit: opts.GasLimit, + }, nil +} + +func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + // Clear any prior observation; executeDuty re-freezes it, so a not-yet-executed duty stays nil. + r.proposerPreferences = nil + return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) +} + +func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { + hasQuorum, roots, err := r.basePreConsensusMsgProcessing(ctx, logger, r, signedMsg) + if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { + // The runner is reused across duties, so a late message for a concluded duty is retryable. + err = NewRetryableError(err) + } + if err != nil { + return fmt.Errorf("failed processing proposer preferences message: %w", err) + } + + // quorum returns true only once (the first time it is reached). + if !hasQuorum { + return nil + } + + // We have quorum and are committed to completing this duty here; the quorum fires only once, + // so a terminal failure below won't be retried. + defer func() { + if err != nil { + r.markDutyFailed(err) + } + }() + + if r.proposerPreferences == nil { + return fmt.Errorf("reached quorum without frozen proposer preferences") + } + + // only 1 root, verified in basePreConsensusMsgProcessing + root := roots[0] + fullSig, err := r.State.ReconstructBeaconSig(r.State.PreConsensusContainer, root, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) + if err != nil { + // If the reconstructed signature is invalid, surface which partial signatures were at fault. + r.FallBackAndVerifyEachSignature(r.State.PreConsensusContainer, root, r.GetShare().Committee, r.GetShare().ValidatorIndex) + return fmt.Errorf("got pre-consensus quorum but it has invalid signatures: %w", err) + } + var signature phase0.BLSSignature + copy(signature[:], fullSig) + + signed := &gloas.SignedProposerPreferences{ + Message: r.proposerPreferences, + Signature: signature, + } + if err := r.beacon.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{signed}); err != nil { + if errors.Is(err, gloas.ErrProposerPreferencesPublishUnavailable) { + // We converged and reconstructed correctly; there is simply no upstream endpoint to publish + // to yet (SIP #94 §5). Record a benign no-op rather than a failure so the known-missing + // endpoint doesn't surface as operator-actionable errors. Returning nil leaves the deferred + // err nil, so markDutyFailed does not also fire. + logger.Debug("proposer preferences reconstructed but publish endpoint unavailable; skipping submit", fields.Slot(r.proposerPreferences.ProposalSlot)) + r.markDutyNotRequired() + return nil + } + return fmt.Errorf("could not submit proposer preferences: %w", err) + } + + r.markDutySucceeded() + logger.Info("✔️ successfully submitted proposer preferences", fields.Slot(r.proposerPreferences.ProposalSlot)) + return nil +} + +func (r *ProposerPreferencesRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { + return fmt.Errorf("no consensus phase for proposer preferences") +} + +func (r *ProposerPreferencesRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return fmt.Errorf("no post-consensus phase for proposer preferences") +} + +func (r *ProposerPreferencesRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + if r.proposerPreferences == nil { + return nil, spectypes.DomainError, fmt.Errorf("no frozen proposer preferences") + } + return []ssz.HashRoot{r.proposerPreferences}, phase0.DomainType(spectypes.DomainProposerPreferences), nil +} + +func (r *ProposerPreferencesRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for proposer preferences") +} + +func (r *ProposerPreferencesRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + proposalSlot := validatorDuty.DutySlot() + + preferences, err := r.buildProposerPreferences(ctx, proposalSlot) + if err != nil { + // Building hits the beacon node (dependent-root fetch) and validator config (fee recipient); + // a failure there is operational, so record a failed duty to surface it in metrics. + logger.Warn("proposer preferences failed: could not build preferences", fields.Slot(proposalSlot), zap.Error(err)) + r.markDutyFailed(err) + return nil + } + + // Freeze the observation: peers' partial signatures are validated and aggregated against exactly + // this object's signing root, so only operators that converged on identical preferences (same + // dependent_root, fee recipient, gas limit) reach quorum. + r.proposerPreferences = preferences + + msg, err := signBeaconObject(ctx, r, r.NetworkConfig, validatorDuty, preferences, proposalSlot, phase0.DomainType(spectypes.DomainProposerPreferences)) + if err != nil { + return fmt.Errorf("could not sign proposer preferences: %w", err) + } + + msgs := &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{msg}, + } + + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + return fmt.Errorf("could not sign/broadcast proposer preferences partial sig: %w", err) + } + return nil +} + +// buildProposerPreferences assembles the preference for the proposal slot from this operator's own +// view: fee recipient and target gas limit from validator config (matching validator registration), +// and the dependent_root of the proposer duties for the proposal slot's epoch — the seed that fixed +// this proposal assignment (SIP #94 §5), fetched per-operator so convergence is over identical roots. +func (r *ProposerPreferencesRunner) buildProposerPreferences(ctx context.Context, proposalSlot phase0.Slot) (*gloas.ProposerPreferences, error) { + validatorPubKey := r.GetShare().ValidatorPubKey + + feeRecipient, err := r.feeRecipientProvider.GetFeeRecipient(validatorPubKey) + if err != nil { + return nil, fmt.Errorf("could not get fee recipient for validator %x: %w", validatorPubKey, err) + } + + gasLimit := r.gasLimit + if gasLimit == 0 { + gasLimit = DefaultGasLimit + } + + epoch := r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot) + dependentRoot, err := r.beacon.ProposerDutiesDependentRoot(ctx, epoch) + if err != nil { + return nil, fmt.Errorf("could not fetch proposer-duties dependent root for epoch %d: %w", epoch, err) + } + + return &gloas.ProposerPreferences{ + DependentRoot: dependentRoot, + ProposalSlot: proposalSlot, + ValidatorIndex: r.GetShare().ValidatorIndex, + FeeRecipient: feeRecipient, + TargetGasLimit: gasLimit, + }, nil +} + +func (r *ProposerPreferencesRunner) GetNetwork() protocolp2p.Network { return r.network } + +func (r *ProposerPreferencesRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } + +func (r *ProposerPreferencesRunner) GetSigner() ekm.BeaconSigner { return r.signer } + +func (r *ProposerPreferencesRunner) GetOperatorSigner() ssvtypes.OperatorSigner { + return r.operatorSigner +} + +// Only BaseRunner is persisted; the frozen observation is transient per-duty state. +func (r *ProposerPreferencesRunner) MarshalJSON() ([]byte, error) { + type proposerPreferencesRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + return json.Marshal(&proposerPreferencesRunnerJSON{BaseRunner: r.BaseRunner}) +} + +func (r *ProposerPreferencesRunner) UnmarshalJSON(data []byte) error { + type proposerPreferencesRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + aux := &proposerPreferencesRunnerJSON{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if aux.BaseRunner == nil { + return fmt.Errorf("missing BaseRunner") + } + r.BaseRunner = aux.BaseRunner + return nil +} + +func (r *ProposerPreferencesRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *ProposerPreferencesRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *ProposerPreferencesRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + return [32]byte{}, fmt.Errorf("could not encode ProposerPreferencesRunner: %w", err) + } + return sha256.Sum256(marshaledRoot), nil +} diff --git a/protocol/v2/types/gloas/proposer_preferences.go b/protocol/v2/types/gloas/proposer_preferences.go index 31b50f0e1b..d3540faed8 100644 --- a/protocol/v2/types/gloas/proposer_preferences.go +++ b/protocol/v2/types/gloas/proposer_preferences.go @@ -33,6 +33,13 @@ type SignedProposerPreferences struct { Signature phase0.BLSSignature `ssz-size:"96"` } +// ErrProposerPreferencesPublishUnavailable is returned by a beacon client's SubmitProposerPreferences +// while no upstream beacon-API endpoint to publish Gloas (ePBS) proposer preferences exists yet +// (SIP #94 §5). It is a sentinel the runner matches (errors.Is) to record a benign no-op instead of a +// duty failure, so the known-missing endpoint doesn't surface as operator-actionable errors; it goes +// away once a real publish client lands. +var ErrProposerPreferencesPublishUnavailable = errors.New("proposer preferences: no upstream beacon-API publication endpoint yet (SIP #94 §5)") + // proposerPreferencesJSON is the beacon-API JSON form: uint64 as a decimal string, root/address as // 0x-hex, per go-eth2-client conventions. type proposerPreferencesJSON struct { From 844605c57428f63eaae3e436ddda528eed08bd2e Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 21:29:57 +0300 Subject: [PATCH 020/150] =?UTF-8?q?gloas:=20emit=20&=20route=20ProposerPre?= =?UTF-8?q?ferences=20duties=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duty handler plus end-to-end wiring for the proposer-preferences duty. - Handler (operator/duties/proposer_preferences.go): emits one duty per upcoming proposal slot a local validator holds, across the current+next-epoch proposer lookahead (MIN_SEED_LOOKAHEAD=1). Gloas-gated, fetches its own proposer duties, dedups per epoch, and executes immediately — duty.Slot is the future proposal slot, so the duty emits in advance. Reorg-driven re-emission and the publication-finality hold are deferred refinements; the pre-fork emission window lands with the fork cutover. - Registered in the scheduler (operator-only) and in SetupRunners (FeeRecipientProvider + GasLimit, mirroring validator registration). - No duty->role mapping change needed: the Boole arm of RunnerRoleForValidatorDuty already maps the BN role via duty.RunnerRole(), and per-role queues auto-create from the registered runners. Emit -> route -> sign -> broadcast now works; cross-operator convergence still needs the message-validation earliness allowance, which drops the future-slot partial signatures until it lands. --- operator/duties/proposer_preferences.go | 129 +++++++++++++++++++ operator/duties/proposer_preferences_test.go | 119 +++++++++++++++++ operator/duties/scheduler.go | 1 + operator/validator/controller.go | 7 + 4 files changed, 256 insertions(+) create mode 100644 operator/duties/proposer_preferences.go create mode 100644 operator/duties/proposer_preferences_test.go diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go new file mode 100644 index 0000000000..3a85187080 --- /dev/null +++ b/operator/duties/proposer_preferences.go @@ -0,0 +1,129 @@ +package duties + +import ( + "context" + + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/observability/log/fields" +) + +// ProposerPreferencesHandler schedules the Gloas (ePBS) proposer-preferences duty (SIP #94 §5): for +// each upcoming proposal slot a local validator holds within the proposer lookahead, it emits one +// duty so the runner broadcasts that validator's fee recipient and target gas limit ahead of the +// slot. Unlike slot-bound duties it emits in advance — duty.Slot is the future proposal slot, and the +// duty executes (the runner signs and broadcasts) as soon as the assignment is known. +type ProposerPreferencesHandler struct { + baseHandler + + // processed records epochs already fetched and handled (preferences emitted, or confirmed to hold + // no local proposals), so each epoch fires once. Accessed only from the HandleDuties goroutine. + processed map[phase0.Epoch]struct{} +} + +func NewProposerPreferencesHandler() *ProposerPreferencesHandler { + return &ProposerPreferencesHandler{ + processed: map[phase0.Epoch]struct{}{}, + } +} + +func (h *ProposerPreferencesHandler) Name() string { + return spectypes.BNRoleProposerPreferences.String() +} + +func (h *ProposerPreferencesHandler) WaitShutdown() {} + +// HandleDuties emits proposer-preferences duties for the current and (once it's a good time to fetch) +// the next epoch — the MIN_SEED_LOOKAHEAD=1 proposer lookahead. Reorg-driven re-emission and the +// publication-finality hold (SIP #94 §5) are deferred refinements; the pre-fork emission window is +// handled with the fork cutover. +func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { + h.logger.Info("starting duty handler") + defer h.logger.Info("duty handler exited") + + next := h.ticker.Next() + for { + select { + case <-ctx.Done(): + return + + case <-next: + slot := h.ticker.Slot() + next = h.ticker.Next() + epoch := h.netCfg.EstimatedEpochAtSlot(slot) + + // Proposer preferences are a Gloas-only duty. + if !h.netCfg.IsGloas(epoch) { + continue + } + + h.emitForEpoch(ctx, epoch, slot) + if h.shouldFetchNextEpoch(slot) { + h.emitForEpoch(ctx, epoch+1, slot) + } + h.evictOutdated(epoch) + + case <-h.indicesChangeCh: + case <-h.reorgEventsCh: + } + } +} + +// emitForEpoch fetches the epoch's proposer assignments for local validators once and emits one +// proposer-preferences duty per assignment, to be executed (broadcast) immediately. +func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch phase0.Epoch, currentSlot phase0.Slot) { + if _, done := h.processed[epoch]; done { + return + } + + shares := h.validatorProvider.SelfParticipatingValidators(epoch) + indices := make([]phase0.ValidatorIndex, 0, len(shares)) + for _, share := range shares { + indices = append(indices, share.ValidatorIndex) + } + if len(indices) == 0 { + return // no local validators yet; retry on the next tick + } + + duties, err := h.beaconNode.ProposerDuties(ctx, epoch, indices) + if err != nil { + h.logger.Warn("failed to fetch proposer duties", fields.Epoch(epoch), zap.Error(err)) + return // retry on the next tick + } + + preferenceDuties := make([]*spectypes.ValidatorDuty, 0, len(duties)) + for _, d := range duties { + preferenceDuties = append(preferenceDuties, &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: d.PubKey, + Slot: d.Slot, // proposal slot — the self-identifying duty.Slot + ValidatorIndex: d.ValidatorIndex, + }) + } + h.processed[epoch] = struct{}{} + + if len(preferenceDuties) == 0 { + return + } + + // Emit now: the runner builds, signs, and broadcasts immediately. duty.Slot is the (future) + // proposal slot, so bound execution by the current slot, not duty.Slot. + deadline := h.netCfg.SlotStartTime(currentSlot + 1) + h.dutiesExecutor.ExecuteDuties(ctx, preferenceDuties, deadline) + + h.logger.Debug("emitted proposer preferences duties", + fields.Epoch(epoch), + fields.Count(len(preferenceDuties)), + ) +} + +// evictOutdated drops processed-epoch markers for epochs before the current one. +func (h *ProposerPreferencesHandler) evictOutdated(currentEpoch phase0.Epoch) { + for epoch := range h.processed { + if epoch < currentEpoch { + delete(h.processed, epoch) + } + } +} diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go new file mode 100644 index 0000000000..60dfe0164a --- /dev/null +++ b/operator/duties/proposer_preferences_test.go @@ -0,0 +1,119 @@ +package duties + +import ( + "context" + "testing" + + eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/types" +) + +// emitForEpoch fetches an epoch's proposer assignments once and emits one proposer-preferences duty +// per assignment; a repeat call short-circuits (the Times(1) expectations fail if it re-fetches). +func TestProposerPreferencesHandler_emitForEpoch_emitsAndCachesPerEpoch(t *testing.T) { + ctrl := gomock.NewController(t) + + epoch := phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + proposalSlot := phase0.Slot(60) + currentSlot := phase0.Slot(40) + pk := phase0.BLSPubKey{1, 2, 3} + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}). + Times(1) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). + Return([]*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}}, nil). + Times(1) + + executed := make(chan []*spectypes.ValidatorDuty, 1) + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.netCfg = networkconfig.TestNetwork + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed} + + h.emitForEpoch(context.Background(), epoch, currentSlot) + h.emitForEpoch(context.Background(), epoch, currentSlot) // cached: must not re-fetch or re-emit + + require.Contains(t, h.processed, epoch) + + require.Len(t, executed, 1) + got := <-executed + require.Len(t, got, 1) + require.Equal(t, spectypes.BNRoleProposerPreferences, got[0].Type) + require.Equal(t, idx, got[0].ValidatorIndex) + require.Equal(t, proposalSlot, got[0].Slot) // duty.Slot is the proposal slot +} + +// With no local validators for the epoch, nothing is emitted and the epoch is left unprocessed so a +// later tick (once validators load) retries. beaconNode/dutiesExecutor are nil here: reaching either +// would panic, asserting the early return. +func TestProposerPreferencesHandler_emitForEpoch_noLocalValidators(t *testing.T) { + ctrl := gomock.NewController(t) + epoch := phase0.Epoch(5) + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch).Return(nil).Times(1) + + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.validatorProvider = vp + + h.emitForEpoch(context.Background(), epoch, phase0.Slot(40)) + + require.NotContains(t, h.processed, epoch) +} + +// When the beacon node reports no local proposals for the epoch, it's marked processed (no retry) and +// nothing is emitted. +func TestProposerPreferencesHandler_emitForEpoch_noProposals(t *testing.T) { + ctrl := gomock.NewController(t) + epoch := phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx}}}).Times(1) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). + Return([]*eth2apiv1.ProposerDuty{}, nil).Times(1) + + executed := make(chan []*spectypes.ValidatorDuty, 1) + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed} + + h.emitForEpoch(context.Background(), epoch, phase0.Slot(40)) + + require.Contains(t, h.processed, epoch) + require.Len(t, executed, 0) +} + +// evictOutdated drops only epochs strictly before the current one. +func TestProposerPreferencesHandler_evictOutdated(t *testing.T) { + h := NewProposerPreferencesHandler() + for _, e := range []phase0.Epoch{4, 5, 6} { + h.processed[e] = struct{}{} + } + + h.evictOutdated(5) + + require.NotContains(t, h.processed, phase0.Epoch(4)) + require.Contains(t, h.processed, phase0.Epoch(5)) + require.Contains(t, h.processed, phase0.Epoch(6)) +} diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index 4a9308ee85..202a667eb9 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -182,6 +182,7 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler { NewValidatorRegistrationHandler(opts.ValidatorRegistrationCh), NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh), NewPTCAttestationHandler(), + NewProposerPreferencesHandler(), ) } return s diff --git a/operator/validator/controller.go b/operator/validator/controller.go index e48c3bf4ec..73edcb2239 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1173,6 +1173,7 @@ func SetupRunners( spectypes.RoleValidatorRegistration, spectypes.RoleVoluntaryExit, spectypes.RolePTCAttester, + spectypes.RoleProposerPreferences, } buildController := func(role spectypes.RunnerRole) *qbftcontroller.Controller { @@ -1278,6 +1279,12 @@ func SetupRunners( runners[role], err = runner.NewPTCAttesterRunner(runner.PTCAttesterRunnerOptions{ BaseRunnerOptions: baseOpts, }) + case spectypes.RoleProposerPreferences: + runners[role], err = runner.NewProposerPreferencesRunner(runner.ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: baseOpts, + FeeRecipientProvider: validatorStore, + GasLimit: options.GasLimit, + }) default: return nil, fmt.Errorf("unexpected duty runner type: %s", role) } From 93656230b473d668ad9887880bf2859cedbce565 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 22:03:12 +0300 Subject: [PATCH 021/150] =?UTF-8?q?gloas:=20ProposerPreferences=20message?= =?UTF-8?q?=20validation=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make ProposerPreferences partial signatures converge cross-operator. Mechanical arms mirror PTC: validRoleAtSlot (Gloas-gated), partial-sig type<->role match + valid-type set, the pre-consensus seen-type limit, and the no-consensus-message rejection. The role also needs the per-signer state machine reconciled to its model — a signer holds preferences for its whole proposer lookahead at once, which the machine (built for monotonic one-slot-at-a-time advancement) did not fit: - messageEarliness allowance = the lookahead span (2 epochs), since a preference rides its future proposal slot. - Exempt the role from the monotonic ErrSlotAlreadyAdvanced check (monotonicSlotRole): else a higher proposal slot poisons the signer's lower ones, frequent on small networks where a validator proposes often. - messageLateness past bound, replacing the replay protection the exemption drops. - Size the per-signer ring to the lookahead (storedSlotCount) so concurrent lookahead slots don't collide and per-slot dedup stays exact. Accepted window is now bounded both ways: [proposal_slot - ~2, + 2 epochs]. 7 unit tests. A per-epoch dutyLimit and a dutyStore-backed duty-assignment check remain as future spam-hardening. --- message/validation/common_checks.go | 28 ++++- message/validation/consensus_validation.go | 6 +- message/validation/const.go | 5 + message/validation/partial_validation.go | 15 ++- .../validation/proposer_preferences_test.go | 112 ++++++++++++++++++ message/validation/seen_msg_types.go | 2 +- message/validation/signed_ssv_message.go | 2 +- message/validation/validation.go | 13 +- 8 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 message/validation/proposer_preferences_test.go diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 31059c8528..083f4192f7 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -15,8 +15,17 @@ func (mv *messageValidator) committeeRole(role spectypes.RunnerRole) bool { return role == spectypes.RoleCommittee || role == spectypes.RoleAggregatorCommittee } +// monotonicSlotRole reports whether a role's signer advances through slots one at a time, so a message +// for a slot below the signer's max is stale and must be rejected. False for committee roles (state is +// slot-keyed across many validators) and for proposer preferences (a signer holds its whole lookahead +// of proposal slots at once, so a lower slot is a concurrent duty, not a stale one — its replay bound +// is the earliness/lateness window instead). +func (mv *messageValidator) monotonicSlotRole(role spectypes.RunnerRole) bool { + return !mv.committeeRole(role) && role != spectypes.RoleProposerPreferences +} + func (mv *messageValidator) validateSlotTime(messageSlot phase0.Slot, role spectypes.RunnerRole, receivedAt time.Time) error { - if earliness := mv.messageEarliness(messageSlot, receivedAt); earliness > clockErrorTolerance { + if earliness := mv.messageEarliness(messageSlot, receivedAt); earliness > clockErrorTolerance+mv.earlySlotAllowance(role) { e := ErrEarlySlotMessage e.got = fmt.Sprintf("early by %v", earliness) return e @@ -36,6 +45,18 @@ func (mv *messageValidator) messageEarliness(slot phase0.Slot, receivedAt time.T return mv.netCfg.SlotStartTime(slot).Sub(receivedAt) } +// earlySlotAllowance returns how far ahead of its slot a message for the role may legitimately +// arrive. Proposer preferences are broadcast across the proposer lookahead — the current epoch plus +// MIN_SEED_LOOKAHEAD — so their proposal-slot messages are expected up to that far in the future; +// every other role acts at (or after) its slot, so the default is none. +func (mv *messageValidator) earlySlotAllowance(role spectypes.RunnerRole) time.Duration { + if role == spectypes.RoleProposerPreferences { + // #nosec G115 -- a small epoch count times slots-per-epoch cannot overflow int64. + return time.Duration(proposerPreferencesEarlyEpochs*mv.netCfg.SlotsPerEpoch) * mv.netCfg.SlotDuration + } + return 0 +} + // messageLateness returns how late message is or 0 if it's not func (mv *messageValidator) messageLateness(slot phase0.Slot, role spectypes.RunnerRole, receivedAt time.Time) time.Duration { var ttl uint64 @@ -48,6 +69,11 @@ func (mv *messageValidator) messageLateness(slot phase0.Slot, role spectypes.Run // Deliberately exempt from the lateness bound: these duties aren't tied to a slot // deadline, so only the early-message check and per-epoch duty limits apply. return 0 + case spectypes.RoleProposerPreferences: + // Preferences are consumed before their proposal slot; allow only a small grace past it so a + // preference for a slot already behind us is rejected as a replay. This is the role's past + // bound, since it is exempt from the monotonic slot-advance check. + ttl = LateSlotAllowance default: return 0 } diff --git a/message/validation/consensus_validation.go b/message/validation/consensus_validation.go index 0937e896de..3c845f8f9d 100644 --- a/message/validation/consensus_validation.go +++ b/message/validation/consensus_validation.go @@ -159,8 +159,10 @@ func (mv *messageValidator) validateConsensusMessageSemantics( return e } - // Rule: Duty role has consensus (true except for ValidatorRegistration, VoluntaryExit, and PTC attestation) - if role == spectypes.RoleValidatorRegistration || role == spectypes.RoleVoluntaryExit || role == spectypes.RolePTCAttester { + // Rule: Duty role has consensus (true except for ValidatorRegistration, VoluntaryExit, PTC + // attestation, and proposer preferences) + if role == spectypes.RoleValidatorRegistration || role == spectypes.RoleVoluntaryExit || + role == spectypes.RolePTCAttester || role == spectypes.RoleProposerPreferences { e := ErrUnexpectedConsensusMessage e.got = role return e diff --git a/message/validation/const.go b/message/validation/const.go index 3c2eb29cc8..ba9d4a38ff 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -24,6 +24,11 @@ const ( encodingOverheadDivisor = 20 // Divisor for message size to get encoding overhead, e.g. 10 for 10%, 20 for 5%. Done this way to keep const int. ) +// proposerPreferencesEarlyEpochs is the proposer-lookahead span in epochs (the current epoch plus +// MIN_SEED_LOOKAHEAD=1): preferences are broadcast up to this far ahead of their proposal slot. It +// bounds both how early such a message may arrive and how many slots of per-signer state to retain. +const proposerPreferencesEarlyEpochs = 2 + const ( signatureSize = 256 signatureOffset = 0 diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index c1aaae9892..0559e19b90 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -122,6 +122,7 @@ func (mv *messageValidator) validatePartialSignatureMessageSemantics( // - ValidatorRegistrationPartialSig for Validator Registration // - VoluntaryExitPartialSig for Voluntary Exit // - PTCAttesterPartialSig for PTC attestation + // - ProposerPreferencesPartialSig for Proposer Preferences if !mv.partialSignatureTypeMatchesRole(partialSignatureMessages.Type, role) { return ErrPartialSignatureTypeRoleMismatch } @@ -172,8 +173,9 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic( signer := signedSSVMessage.OperatorIDs[0] operatorState := state.OperatorState(committeeInfo.signerIndex(signer)) - // Rule: Height must not be "old". I.e., signer must not have already advanced to a later slot. - if !mv.committeeRole(role) { // Rule only for validator runners + // Rule: Height must not be "old" — a monotonic-slot signer must not regress to an earlier slot + // once it has advanced (see monotonicSlotRole for the exemptions). + if mv.monotonicSlotRole(role) { maxSlot := operatorState.MaxSlot() if maxSlot != 0 && maxSlot > partialSignatureMessages.Slot { e := ErrSlotAlreadyAdvanced @@ -198,6 +200,7 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic( // - 1 ValidatorRegistrationPartialSig for Validator Registration // - 1 VoluntaryExitPartialSig for Voluntary Exit // - 1 PTCAttesterPartialSig for PTC attestation + // - 1 ProposerPreferencesPartialSig for Proposer Preferences if err := validatePartialSignatureMessageLimit(partialSignatureMessages, receivedFrom, signerState); err != nil { return err } @@ -281,7 +284,8 @@ func validatePartialSignatureMessageLimit( switch m.Type { case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, - spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: + spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig, + spectypes.ProposerPreferencesPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPreConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. e := ErrTooManyPartialSigMessage @@ -357,7 +361,8 @@ func (mv *messageValidator) validPartialSigMsgType(msgType spectypes.PartialSigM spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, - spectypes.PTCAttesterPartialSig: + spectypes.PTCAttesterPartialSig, + spectypes.ProposerPreferencesPartialSig: return true default: return false @@ -382,6 +387,8 @@ func (mv *messageValidator) partialSignatureTypeMatchesRole(msgType spectypes.Pa return msgType == spectypes.AggregatorCommitteePartialSig || msgType == spectypes.PostConsensusPartialSig case spectypes.RolePTCAttester: return msgType == spectypes.PTCAttesterPartialSig + case spectypes.RoleProposerPreferences: + return msgType == spectypes.ProposerPreferencesPartialSig default: return false } diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go new file mode 100644 index 0000000000..a3e50da479 --- /dev/null +++ b/message/validation/proposer_preferences_test.go @@ -0,0 +1,112 @@ +package validation + +import ( + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/networkconfig" +) + +func TestPartialSignatureTypeMatchesRole_ProposerPreferences(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.partialSignatureTypeMatchesRole(spectypes.ProposerPreferencesPartialSig, spectypes.RoleProposerPreferences)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.PostConsensusPartialSig, spectypes.RoleProposerPreferences)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.ProposerPreferencesPartialSig, spectypes.RolePTCAttester)) +} + +func TestValidPartialSigMsgType_ProposerPreferences(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.validPartialSigMsgType(spectypes.ProposerPreferencesPartialSig)) +} + +// ProposerPreferences partial sigs ride the future proposal slot, so validateSlotTime must allow them +// up to the proposer-lookahead window early — but no other role, and not beyond the window. +func TestValidateSlotTime_ProposerPreferencesEarliness(t *testing.T) { + netCfg := networkconfig.TestNetwork + mv := &messageValidator{netCfg: netCfg} + + slot := phase0.Slot(1000) + allowance := time.Duration(proposerPreferencesEarlyEpochs*netCfg.SlotsPerEpoch) * netCfg.SlotDuration + + tt := []struct { + name string + role spectypes.RunnerRole + earlyBy time.Duration + accepted bool + }{ + {"preferences within the lookahead window", spectypes.RoleProposerPreferences, allowance - time.Second, true}, + {"preferences beyond the lookahead window", spectypes.RoleProposerPreferences, allowance + time.Minute, false}, + {"another role gets no early allowance", spectypes.RoleProposer, allowance - time.Second, false}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + receivedAt := netCfg.SlotStartTime(slot).Add(-tc.earlyBy) + err := mv.validateSlotTime(slot, tc.role, receivedAt) + if tc.accepted { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrEarlySlotMessage) + } + }) + } +} + +// ProposerPreferences is exempt from the monotonic slot-advance rule (a signer holds its whole +// lookahead at once); other validator roles still enforce it. +func TestMonotonicSlotRole_ProposerPreferences(t *testing.T) { + mv := &messageValidator{} + require.False(t, mv.monotonicSlotRole(spectypes.RoleProposerPreferences)) + require.True(t, mv.monotonicSlotRole(spectypes.RoleProposer)) + require.True(t, mv.monotonicSlotRole(spectypes.RoleValidatorRegistration)) + require.False(t, mv.monotonicSlotRole(spectypes.RoleCommittee)) +} + +func TestStoredSlotCount_ProposerPreferences(t *testing.T) { + netCfg := networkconfig.TestNetwork + mv := &messageValidator{netCfg: netCfg} + + require.Equal(t, mv.maxStoredSlots(), mv.storedSlotCount(spectypes.RoleProposer)) + require.Equal(t, + proposerPreferencesEarlyEpochs*netCfg.SlotsPerEpoch+mv.maxStoredSlots(), + mv.storedSlotCount(spectypes.RoleProposerPreferences)) +} + +// Two proposal slots exactly one default-ring apart collide in the default ring but stay distinct in +// the lookahead-sized proposer-preferences ring, keeping per-slot dedup exact. +func TestProposerPreferencesRingAvoidsLookaheadCollision(t *testing.T) { + netCfg := networkconfig.TestNetwork + mv := &messageValidator{netCfg: netCfg} + + slotA := phase0.Slot(1000) + slotB := slotA + phase0.Slot(mv.maxStoredSlots()) // collides with slotA in the default ring + + osDefault := newOperatorState(mv.maxStoredSlots()) + osDefault.SetSignerStateForSlot(slotA, 0, &SignerStateForSlotRound{Slot: slotA}) + osDefault.SetSignerStateForSlot(slotB, 0, &SignerStateForSlotRound{Slot: slotB}) + require.Nil(t, osDefault.GetSignerStateForSlot(slotA), "default ring should drop slotA on collision") + + osPrefs := newOperatorState(mv.storedSlotCount(spectypes.RoleProposerPreferences)) + osPrefs.SetSignerStateForSlot(slotA, 0, &SignerStateForSlotRound{Slot: slotA}) + osPrefs.SetSignerStateForSlot(slotB, 0, &SignerStateForSlotRound{Slot: slotB}) + require.NotNil(t, osPrefs.GetSignerStateForSlot(slotA)) + require.NotNil(t, osPrefs.GetSignerStateForSlot(slotB)) +} + +// With the monotonic check skipped, lateness is the role's replay bound: a preference around its +// proposal slot is fine, one for a slot well behind is late. +func TestMessageLateness_ProposerPreferences(t *testing.T) { + netCfg := networkconfig.TestNetwork + mv := &messageValidator{netCfg: netCfg} + slot := phase0.Slot(1000) + + notLate := mv.messageLateness(slot, spectypes.RoleProposerPreferences, netCfg.SlotStartTime(slot)) + require.LessOrEqual(t, notLate, time.Duration(0)) + + late := mv.messageLateness(slot, spectypes.RoleProposerPreferences, netCfg.SlotStartTime(slot+100)) + require.Greater(t, late, time.Duration(0)) +} diff --git a/message/validation/seen_msg_types.go b/message/validation/seen_msg_types.go index 14233ac2bc..c8a591b2ee 100644 --- a/message/validation/seen_msg_types.go +++ b/message/validation/seen_msg_types.go @@ -80,7 +80,7 @@ func (c *SeenMsgTypes) RecordConsensusMessage(signedSSVMessage *spectypes.Signed // RecordPartialSignatureMessage updates the counts based on the provided partial signature message type. func (c *SeenMsgTypes) RecordPartialSignatureMessage(messages *spectypes.PartialSignatureMessages) error { switch messages.Type { - case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: + case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig, spectypes.ProposerPreferencesPartialSig: c.recordPreConsensus() case spectypes.PostConsensusPartialSig: c.recordPostConsensus() diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index 5f8ef8abf7..88002f71d2 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -161,7 +161,7 @@ func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot return isInBooleFork case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution: return !isInBooleFork - case spectypes.RolePTCAttester: + case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences: return isInGloas default: return false diff --git a/message/validation/validation.go b/message/validation/validation.go index 25b9e547ff..1322e58bce 100644 --- a/message/validation/validation.go +++ b/message/validation/validation.go @@ -386,7 +386,7 @@ func (mv *messageValidator) validatorState(key spectypes.MessageID, committeeInf cs := &ValidatorState{ committeeID: committeeInfo.committeeID, operators: make([]*OperatorState, len(committeeInfo.committee)), - storedSlotCount: mv.maxStoredSlots(), + storedSlotCount: mv.storedSlotCount(key.GetRoleType()), } mv.states.Set(key, cs, ttlcache.DefaultTTL) return cs @@ -396,3 +396,14 @@ func (mv *messageValidator) validatorState(key spectypes.MessageID, committeeInf func (mv *messageValidator) maxStoredSlots() uint64 { return mv.netCfg.SlotsPerEpoch + LateSlotAllowance } + +// storedSlotCount returns how many recent slots of per-signer state a role retains. Proposer +// preferences are broadcast across the whole proposer lookahead, so their ring must span it (on top +// of the normal recent-slots buffer) to give every lookahead slot a distinct ring slot and keep +// per-slot dedup exact; every other role only ever sees roughly the current slot. +func (mv *messageValidator) storedSlotCount(role spectypes.RunnerRole) uint64 { + if role == spectypes.RoleProposerPreferences { + return proposerPreferencesEarlyEpochs*mv.netCfg.SlotsPerEpoch + mv.maxStoredSlots() + } + return mv.maxStoredSlots() +} From 4f09b43211bda81b25b9f5e0dfcb2e99ce67bad9 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 23:05:56 +0300 Subject: [PATCH 022/150] =?UTF-8?q?gloas:=20deprecate=20ValidatorRegistrat?= =?UTF-8?q?ion=20at=20the=20fork=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidatorRegistration is superseded by proposer preferences at the Gloas fork, so stop it at every point once Gloas is active: - Message validation rejects ValidatorRegistrationPartialSig for Gloas-or-later slots (validRoleAtSlot). - The duty handler stops emitting — both the periodic path and the event-driven enqueue. - The periodic VRSubmitter stops submitting registrations to the beacon node. All gate on (*Beacon).IsGloas, which is false without a scheduled Gloas fork, so pre-Gloas behavior is unchanged and the VR runner simply goes idle. Adds a TestNetworkWithGloas fixture (TestNetwork has no Gloas fork) and tests for the validation reject and the handler emission skip. --- .../validation/proposer_preferences_test.go | 13 +++++++++++++ message/validation/signed_ssv_message.go | 5 ++++- networkconfig/test-network.go | 13 +++++++++++++ operator/duties/validator_registration.go | 9 +++++++++ .../duties/validator_registration_test.go | 19 +++++++++++++++++++ .../v2/ssv/runner/validator_registration.go | 4 ++++ 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index a3e50da479..990b33d74f 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -110,3 +110,16 @@ func TestMessageLateness_ProposerPreferences(t *testing.T) { late := mv.messageLateness(slot, spectypes.RoleProposerPreferences, netCfg.SlotStartTime(slot+100)) require.Greater(t, late, time.Duration(0)) } + +// ValidatorRegistration is deprecated at the Gloas fork — valid pre-Gloas, rejected for Gloas slots. +func TestValidRoleAtSlot_ValidatorRegistrationDeprecatedAtGloas(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + mv := &messageValidator{netCfg: netCfg} + + preGloasSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) + gloasSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) + + require.True(t, mv.validRoleAtSlot(spectypes.RoleValidatorRegistration, preGloasSlot)) + require.False(t, mv.validRoleAtSlot(spectypes.RoleValidatorRegistration, gloasSlot)) +} diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index 88002f71d2..ef510f15ef 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -155,8 +155,11 @@ func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot isInBooleFork := mv.netCfg.BooleForkAtSlot(slot) isInGloas := mv.netCfg.IsGloas(mv.netCfg.EstimatedEpochAtSlot(slot)) switch roleType { - case spectypes.RoleCommittee, spectypes.RoleProposer, spectypes.RoleValidatorRegistration, spectypes.RoleVoluntaryExit: + case spectypes.RoleCommittee, spectypes.RoleProposer, spectypes.RoleVoluntaryExit: return true + case spectypes.RoleValidatorRegistration: + // Deprecated at the Gloas fork — superseded by proposer preferences (§5). Pre-Gloas unchanged. + return !isInGloas case spectypes.RoleAggregatorCommittee: return isInBooleFork case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution: diff --git a/networkconfig/test-network.go b/networkconfig/test-network.go index 81599884b4..8850cc993a 100644 --- a/networkconfig/test-network.go +++ b/networkconfig/test-network.go @@ -1,6 +1,7 @@ package networkconfig import ( + "maps" "math" "math/big" "os" @@ -15,6 +16,18 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" ) +// TestNetworkWithGloas returns a copy of TestNetwork with the Gloas (ePBS) fork scheduled at +// forkEpoch, for tests that exercise Gloas fork-gated behavior. TestNetwork itself has no Gloas fork. +func TestNetworkWithGloas(forkEpoch phase0.Epoch) *Network { + beacon := *TestNetwork.Beacon + beacon.Forks = maps.Clone(TestNetwork.Beacon.Forks) + beacon.Forks[DataVersionGloas] = phase0.Fork{Epoch: forkEpoch} + + network := *TestNetwork + network.Beacon = &beacon + return &network +} + var TestNetwork = &Network{ Beacon: &Beacon{ Name: string(spectypes.BeaconTestNetwork), diff --git a/operator/duties/validator_registration.go b/operator/duties/validator_registration.go index 85d4f6f067..b556aaf44e 100644 --- a/operator/duties/validator_registration.go +++ b/operator/duties/validator_registration.go @@ -161,6 +161,10 @@ func (h *ValidatorRegistrationHandler) HandleDuties(ctx context.Context) { continue } dutySlot := blockSlot + validatorRegistrationDutySlotsToPostpone + // Deprecated at the Gloas fork: don't enqueue registrations whose duty slot is Gloas-or-later. + if h.netCfg.IsGloas(h.netCfg.EstimatedEpochAtSlot(dutySlot)) { + continue + } earliestExecutionSlot := blockSlot + validatorRegistrationExecutionSlotsToPostpone // No de-dup on enqueue: entries are idempotent and bounded. The duty @@ -204,6 +208,11 @@ func (h *ValidatorRegistrationHandler) processExecution(ctx context.Context, epo trace.WithAttributes(observability.BeaconSlotAttribute(slot))) defer span.End() + // Validator registration is deprecated at the Gloas fork — superseded by proposer preferences (§5). + if h.netCfg.IsGloas(epoch) { + return + } + shares := h.validatorProvider.SelfValidators() duties := make([]*spectypes.ValidatorDuty, 0, len(h.eventQueue)+len(shares)) diff --git a/operator/duties/validator_registration_test.go b/operator/duties/validator_registration_test.go index b144a16007..8a9bf36e99 100644 --- a/operator/duties/validator_registration_test.go +++ b/operator/duties/validator_registration_test.go @@ -10,10 +10,29 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/types" ) +// At and after the Gloas fork, validator registration is deprecated, so processExecution emits +// nothing (validatorProvider is nil here: it would panic if the gate failed to short-circuit). +func TestValidatorRegistrationHandler_processExecution_skippedAtGloas(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + + executed := make(chan []*spectypes.ValidatorDuty, 1) + h := NewValidatorRegistrationHandler(nil) + h.logger = zap.NewNop() + h.netCfg = netCfg + h.dutiesExecutor = &captureExecutor{executed: executed} + + h.processExecution(context.Background(), gloasEpoch, phase0.Slot(uint64(gloasEpoch)*netCfg.SlotsPerEpoch)) + + require.Len(t, executed, 0) +} + func TestValidatorRegistrationHandler_HandleDuties(t *testing.T) { t.Run("duty triggered by ticker", func(t *testing.T) { synctest.Test(t, func(t *testing.T) { diff --git a/protocol/v2/ssv/runner/validator_registration.go b/protocol/v2/ssv/runner/validator_registration.go index a472afddc8..9cee5f9746 100644 --- a/protocol/v2/ssv/runner/validator_registration.go +++ b/protocol/v2/ssv/runner/validator_registration.go @@ -383,6 +383,10 @@ func (s *VRSubmitter) start(ctx context.Context, ticker slotticker.SlotTicker) { currentSlot := ticker.Slot() currentEpoch := config.EstimatedEpochAtSlot(currentSlot) + // Validator registration is deprecated at the Gloas fork; stop submitting once it's active. + if config.IsGloas(currentEpoch) { + continue + } slotInEpoch := uint64(currentSlot) % config.SlotsPerEpoch // Select registrations to submit. From cc843ab623376ed590d1789108cbb8db9d9c7d1c Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 23:20:05 +0300 Subject: [PATCH 023/150] =?UTF-8?q?gloas:=20pre-fork=20ProposerPreferences?= =?UTF-8?q?=20emission=20window=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the fork cutover: as ValidatorRegistration stops at the Gloas fork, proposer preferences for the first Gloas epoch go out an epoch early so builders have them before the fork. - networkconfig: Beacon.GloasForkEpoch() accessor; Network.InGloasPriorWindow, mirroring inBoolePriorWindow (gloasPriorWindowEpochs = MIN_SEED_LOOKAHEAD = 1). - The prefs handler's per-tick logic is extracted to emitForTick: steady state emits the current+next epoch; the pre-fork window emits the first Gloas epoch (emitForEpoch(epoch+1)). 3 tests (GloasForkEpoch, InGloasPriorWindow boundaries, emitForTick pre-fork + steady-state dispatch). --- networkconfig/beacon.go | 8 ++++ networkconfig/beacon_gloas_test.go | 24 ++++++++++ networkconfig/network.go | 18 ++++++++ operator/duties/proposer_preferences.go | 39 +++++++++------- operator/duties/proposer_preferences_test.go | 48 ++++++++++++++++++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index b625e0f2ab..0fffe2540a 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -197,6 +197,14 @@ func (b *Beacon) IsGloas(epoch phase0.Epoch) bool { return ok && epoch >= fork.Epoch } +// GloasForkEpoch returns the scheduled Gloas (ePBS) fork epoch and whether a Gloas fork is present in +// the schedule. An unscheduled far-future epoch is returned as-is; callers that gate on it (IsGloas, +// InGloasPriorWindow) treat it as never active via the epoch comparison. +func (b *Beacon) GloasForkEpoch() (phase0.Epoch, bool) { + fork, ok := b.Forks[DataVersionGloas] + return fork.Epoch, ok +} + func (b *Beacon) AssertSame(other *Beacon) error { if b.Name != other.Name { return fmt.Errorf("different Name") diff --git a/networkconfig/beacon_gloas_test.go b/networkconfig/beacon_gloas_test.go index cd1ff2606f..0354c8f465 100644 --- a/networkconfig/beacon_gloas_test.go +++ b/networkconfig/beacon_gloas_test.go @@ -29,3 +29,27 @@ func TestBeacon_IsGloas(t *testing.T) { require.True(t, scheduled.IsGloas(100)) require.True(t, scheduled.IsGloas(101)) } + +func TestBeacon_GloasForkEpoch(t *testing.T) { + _, ok := (&Beacon{Forks: map[spec.DataVersion]phase0.Fork{}}).GloasForkEpoch() + require.False(t, ok) + + epoch, ok := (&Beacon{Forks: map[spec.DataVersion]phase0.Fork{ + DataVersionGloas: {Epoch: 100}, + }}).GloasForkEpoch() + require.True(t, ok) + require.Equal(t, phase0.Epoch(100), epoch) +} + +func TestNetwork_InGloasPriorWindow(t *testing.T) { + const gloasEpoch = 100 + netCfg := TestNetworkWithGloas(gloasEpoch) + slotInEpoch := func(e phase0.Epoch) phase0.Slot { return phase0.Slot(uint64(e) * netCfg.SlotsPerEpoch) } + + require.False(t, netCfg.InGloasPriorWindow(slotInEpoch(gloasEpoch-2)), "outside the lookahead window") + require.True(t, netCfg.InGloasPriorWindow(slotInEpoch(gloasEpoch-1)), "the prior window") + require.False(t, netCfg.InGloasPriorWindow(slotInEpoch(gloasEpoch)), "already at the fork") + + // No Gloas fork scheduled → never in the window. + require.False(t, TestNetwork.InGloasPriorWindow(slotInEpoch(gloasEpoch-1))) +} diff --git a/networkconfig/network.go b/networkconfig/network.go index d5fb9ba2ac..63a9869575 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -46,6 +46,8 @@ const boolePriorWindowEpochs = phase0.Epoch(1) // epochs before Boole to subscri // long or those messages are dropped by the subscription filter before validation ever sees them. const booleSubsequentWindowLateSlots = phase0.Slot(2) +const gloasPriorWindowEpochs = phase0.Epoch(1) // MIN_SEED_LOOKAHEAD: epochs before Gloas to pre-emit proposer preferences + // StorageName returns a config name used to make sure the stored network doesn't differ. // It combines the network name with the storage-compatibility token. func (n Network) StorageName() string { @@ -174,3 +176,19 @@ func (n Network) inBooleSubsequentWindowWithSlots(slot phase0.Slot, windowSlots end := start + windowSlots return slot >= start && slot < end } + +// InGloasPriorWindow reports whether slot falls in the MIN_SEED_LOOKAHEAD epoch(s) immediately before +// the Gloas fork, where proposers pre-broadcast preferences for the first Gloas epoch so builders have +// them before the fork (SIP #94 §5). False when no Gloas fork is scheduled. +func (n Network) InGloasPriorWindow(slot phase0.Slot) bool { + gloasEpoch, ok := n.GloasForkEpoch() + if !ok { + return false + } + priorWindowStart := phase0.Epoch(0) + if gloasPriorWindowEpochs <= gloasEpoch { + priorWindowStart = gloasEpoch - gloasPriorWindowEpochs + } + epoch := n.EstimatedEpochAtSlot(slot) + return epoch >= priorWindowStart && epoch < gloasEpoch +} diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index 3a85187080..db316fa4bc 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -35,10 +35,10 @@ func (h *ProposerPreferencesHandler) Name() string { func (h *ProposerPreferencesHandler) WaitShutdown() {} -// HandleDuties emits proposer-preferences duties for the current and (once it's a good time to fetch) -// the next epoch — the MIN_SEED_LOOKAHEAD=1 proposer lookahead. Reorg-driven re-emission and the -// publication-finality hold (SIP #94 §5) are deferred refinements; the pre-fork emission window is -// handled with the fork cutover. +// HandleDuties emits proposer-preferences duties across the proposer lookahead (current + next epoch, +// MIN_SEED_LOOKAHEAD=1). In the epoch immediately before the Gloas fork it pre-emits the first Gloas +// epoch's preferences (SIP #94 §5) so builders have them before the fork. Reorg-driven re-emission and +// the publication-finality hold are deferred refinements. func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.logger.Info("starting duty handler") defer h.logger.Info("duty handler exited") @@ -52,18 +52,7 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { case <-next: slot := h.ticker.Slot() next = h.ticker.Next() - epoch := h.netCfg.EstimatedEpochAtSlot(slot) - - // Proposer preferences are a Gloas-only duty. - if !h.netCfg.IsGloas(epoch) { - continue - } - - h.emitForEpoch(ctx, epoch, slot) - if h.shouldFetchNextEpoch(slot) { - h.emitForEpoch(ctx, epoch+1, slot) - } - h.evictOutdated(epoch) + h.emitForTick(ctx, slot) case <-h.indicesChangeCh: case <-h.reorgEventsCh: @@ -71,6 +60,24 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { } } +// emitForTick emits the lookahead's preferences for the tick's slot: the current epoch (plus the next, +// once it's a good time to fetch) in steady state, or the first Gloas epoch when in the pre-fork +// window. Outside both it does nothing (pre-Gloas, no preferences yet). +func (h *ProposerPreferencesHandler) emitForTick(ctx context.Context, slot phase0.Slot) { + epoch := h.netCfg.EstimatedEpochAtSlot(slot) + switch { + case h.netCfg.IsGloas(epoch): + h.emitForEpoch(ctx, epoch, slot) + if h.shouldFetchNextEpoch(slot) { + h.emitForEpoch(ctx, epoch+1, slot) + } + h.evictOutdated(epoch) + case h.netCfg.InGloasPriorWindow(slot): + // epoch+1 is GLOAS_FORK_EPOCH throughout the prior window (MIN_SEED_LOOKAHEAD=1). + h.emitForEpoch(ctx, epoch+1, slot) + } +} + // emitForEpoch fetches the epoch's proposer assignments for local validators once and emits one // proposer-preferences duty per assignment, to be executed (broadcast) immediately. func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch phase0.Epoch, currentSlot phase0.Slot) { diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index 60dfe0164a..8da5e9cf99 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -117,3 +117,51 @@ func TestProposerPreferencesHandler_evictOutdated(t *testing.T) { require.Contains(t, h.processed, phase0.Epoch(5)) require.Contains(t, h.processed, phase0.Epoch(6)) } + +// emitForTick emits the first Gloas epoch's preferences both in steady state (a slot in that epoch) +// and pre-fork (a slot in the epoch immediately before the fork). +func TestProposerPreferencesHandler_emitForTick(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + + tt := []struct { + name string + slot phase0.Slot + }{ + {"pre-fork window emits the first Gloas epoch", phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch)}, + {"steady state emits the current Gloas epoch", phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch)}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + idx := phase0.ValidatorIndex(7) + pk := phase0.BLSPubKey{1, 2, 3} + proposalSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) // a slot in the Gloas fork epoch + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(phase0.Epoch(gloasEpoch)). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}).Times(1) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDuties(gomock.Any(), phase0.Epoch(gloasEpoch), []phase0.ValidatorIndex{idx}). + Return([]*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}}, nil).Times(1) + + executed := make(chan []*spectypes.ValidatorDuty, 1) + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.netCfg = netCfg + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed} + + h.emitForTick(context.Background(), tc.slot) + + require.Len(t, executed, 1) + got := <-executed + require.Len(t, got, 1) + require.Equal(t, spectypes.BNRoleProposerPreferences, got[0].Type) + require.Equal(t, proposalSlot, got[0].Slot) + }) + } +} From 3659acedb622800f9017176611439dcfb43ecd82 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 23:39:29 +0300 Subject: [PATCH 024/150] =?UTF-8?q?gloas:=20ProposerPreferences=20spam=20h?= =?UTF-8?q?ardening=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer two more checks onto proposer-preferences message validation: - dutyLimit: at most SlotsPerEpoch preferences per validator per epoch (a validator proposes at most once per slot). - validateBeaconDuty: the proposal slot must be a real proposer assignment for the validator (dutyStore.Proposer). Preferences ride a future slot whose epoch may not be fetched yet, so the check is tolerated (RANDAO-style) while the epoch is unfetched and enforced once it's known — rejecting forged slots without false-rejecting valid preferences mid-fetch. 2 tests. --- message/validation/common_checks.go | 15 ++++++++ .../validation/proposer_preferences_test.go | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 083f4192f7..6c42519282 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -107,6 +107,7 @@ func (mv *messageValidator) validateDutyCount( // Rule: valid number of duties per epoch: // - 2 for aggregation, voluntary exit and validator registration // - 2*V for Committee and AggregatorCommittee duty (where V is the number of validators in the cluster) (if no validator is doing sync committee in this epoch) + // - SlotsPerEpoch for proposer preferences // - else, accept if dutyCount > dutyLimit { e := ErrTooManyDutiesPerEpoch @@ -149,6 +150,10 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true + case spectypes.RoleProposerPreferences: + // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences per epoch. + return mv.netCfg.SlotsPerEpoch, true + default: return 0, false } @@ -181,6 +186,16 @@ func (mv *messageValidator) validateBeaconDuty( } } + // Rule: For a proposer-preferences message, require a real proposer assignment for the validator at + // the slot — but only once the slot's epoch is fetched. Preferences ride a future proposal slot + // whose epoch may still be in flight; tolerate that (the earliness/lateness window bounds the slot). + if role == spectypes.RoleProposerPreferences { + validatorIndex := indices[0] + if mv.dutyStore.Proposer.IsEpochSet(epoch) && mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { + return ErrNoDuty + } + } + // Rule: For a sync committee aggregation duty message, we check if the validator is assigned to it if role == ssvtypes.RoleSyncCommitteeContribution { period := mv.netCfg.EstimatedSyncCommitteePeriodAtEpoch(epoch) diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index 990b33d74f..dcfb9e7534 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -4,11 +4,13 @@ import ( "testing" "time" + eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/operator/duties/dutystore" ) func TestPartialSignatureTypeMatchesRole_ProposerPreferences(t *testing.T) { @@ -123,3 +125,36 @@ func TestValidRoleAtSlot_ValidatorRegistrationDeprecatedAtGloas(t *testing.T) { require.True(t, mv.validRoleAtSlot(spectypes.RoleValidatorRegistration, preGloasSlot)) require.False(t, mv.validRoleAtSlot(spectypes.RoleValidatorRegistration, gloasSlot)) } + +func TestDutyLimit_ProposerPreferences(t *testing.T) { + mv := &messageValidator{netCfg: networkconfig.TestNetwork} + msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleProposerPreferences) + + limit, ok := mv.dutyLimit(msgID, 0, nil) + require.True(t, ok) + require.Equal(t, mv.netCfg.SlotsPerEpoch, limit) +} + +// A proposer-preferences message must reference a real proposal slot for the validator once the +// slot's epoch is fetched; an unfetched epoch is tolerated (the duty fetch may be in flight). +func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) { + netCfg := networkconfig.TestNetwork + const epoch = phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + slot := phase0.Slot(uint64(epoch)*netCfg.SlotsPerEpoch + 3) + + ds := dutystore.New() + ds.Proposer.Set(epoch, []dutystore.StoreDuty[eth2apiv1.ProposerDuty]{ + {Slot: slot, ValidatorIndex: idx, Duty: ð2apiv1.ProposerDuty{Slot: slot, ValidatorIndex: idx}, InCommittee: true}, + }) + mv := &messageValidator{netCfg: netCfg, dutyStore: ds} + + indices := []phase0.ValidatorIndex{idx} + // Assigned proposal slot → accepted. + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, slot, indices, false)) + // Same (fetched) epoch, unassigned slot → rejected. + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, slot+1, indices, false), ErrNoDuty) + // Unfetched epoch → tolerated. + unfetched := phase0.Slot(uint64(epoch+10) * netCfg.SlotsPerEpoch) + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, unfetched, indices, false)) +} From 06bb3c8303da11f5b309149db997f90fe3772809 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 24 Jun 2026 23:56:04 +0300 Subject: [PATCH 025/150] =?UTF-8?q?gloas:=20ProposerPreferences=20runner?= =?UTF-8?q?=20unit=20tests=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror ptc_attester_test.go: constructor single-share validation, expectedPreConsensusRootsAndDomain (error before freeze; the frozen preference under DomainProposerPreferences after), and the no-consensus / no-post-consensus rejects. --- .../ssv/runner/proposer_preferences_test.go | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 protocol/v2/ssv/runner/proposer_preferences_test.go diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go new file mode 100644 index 0000000000..cc699725a2 --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -0,0 +1,51 @@ +package runner + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { + _, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{}) + require.Error(t, err) + + r, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {}}, + }, + }) + require.NoError(t, err) + require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).BaseRunner.RunnerRoleType) +} + +// The runner validates and aggregates incoming partial signatures against its own frozen preference: +// there is no expected root before executeDuty has built and frozen one, and afterwards it is exactly +// that preference's root under DomainProposerPreferences. +func TestProposerPreferencesRunner_ExpectedPreConsensusRootsAndDomain(t *testing.T) { + r := &ProposerPreferencesRunner{} + + _, _, err := r.expectedPreConsensusRootsAndDomain() + require.Error(t, err) + + prefs := &gloas.ProposerPreferences{DependentRoot: phase0.Root{0x01}, ProposalSlot: 5, ValidatorIndex: 7} + r.proposerPreferences = prefs + roots, domain, err := r.expectedPreConsensusRootsAndDomain() + require.NoError(t, err) + require.Equal(t, []ssz.HashRoot{prefs}, roots) + require.Equal(t, phase0.DomainType(spectypes.DomainProposerPreferences), domain) +} + +// Proposer preferences have no consensus or post-consensus phase; those entry points must reject. +func TestProposerPreferencesRunner_NoConsensusPhases(t *testing.T) { + r := &ProposerPreferencesRunner{} + require.Error(t, r.ProcessConsensus(context.Background(), zap.NewNop(), nil)) + require.Error(t, r.ProcessPostConsensus(context.Background(), zap.NewNop(), nil)) +} From fdad68086147f0f6250a8543246977248464f295 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 09:28:56 +0300 Subject: [PATCH 026/150] =?UTF-8?q?gloas:=20ProposerPreferences=20reorg=20?= =?UTF-8?q?re-emission=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a duty-dependent-root change, the prefs handler's reorg case now clears the emitted-epoch markers so the next tick re-fetches the latest dependent_root and re-emits the lookahead. Because dependent_root is part of the gossip tuple, the re-emission is a distinct preference, not a replacement — per §5. Conservative: every ReorgEvent is by construction a dependent-root change, so the whole lookahead is re-emitted. 1 test. --- operator/duties/proposer_preferences.go | 10 ++++++++++ operator/duties/proposer_preferences_test.go | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index db316fa4bc..e43b965e2a 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -55,11 +55,21 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.emitForTick(ctx, slot) case <-h.indicesChangeCh: + case <-h.reorgEventsCh: + h.handleReorg() } } } +// handleReorg drops the emitted-epoch markers after a duty-dependent-root change so the next tick +// re-fetches and re-emits the lookahead's preferences. Because dependent_root is part of the gossip +// tuple (SIP #94 §5), the re-emission is a distinct preference, not a replacement of the prior one. +func (h *ProposerPreferencesHandler) handleReorg() { + h.logger.Debug("🔀 reorg: re-emitting proposer preferences on next tick") + clear(h.processed) +} + // emitForTick emits the lookahead's preferences for the tick's slot: the current epoch (plus the next, // once it's a good time to fetch) in steady state, or the first Gloas epoch when in the pre-fork // window. Outside both it does nothing (pre-Gloas, no preferences yet). diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index 8da5e9cf99..fe0d7c76a2 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -165,3 +165,16 @@ func TestProposerPreferencesHandler_emitForTick(t *testing.T) { }) } } + +// A reorg drops the emitted-epoch markers so the next tick re-fetches and re-emits the lookahead. +func TestProposerPreferencesHandler_handleReorg_clearsProcessed(t *testing.T) { + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + for _, e := range []phase0.Epoch{100, 101} { + h.processed[e] = struct{}{} + } + + h.handleReorg() + + require.Empty(t, h.processed) +} From 71b82b7a0410561f15da44931fdfdac99dcfb6d9 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 12:26:34 +0300 Subject: [PATCH 027/150] =?UTF-8?q?gloas:=20ProposerPreferences=20multi-sl?= =?UTF-8?q?ot=20runner=20+=20review=20fixes=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review of the ProposerPreferences vertical. - Multi-slot runner: a validator can hold several lookahead proposal slots at once, but preference messages route by MessageID (validator + role), so a single per-(validator, role) runner state could only hold one slot — concurrent slots overwrote or rejected each other. Restructure ProposerPreferencesRunner into a dispatcher holding one proposerPreferencesSlotRunner per proposal slot, routing StartNewDuty / ProcessPreConsensus by slot and aggregating HasRunningDuty; past slots are evicted on each new duty. - Re-emit the lookahead on indices change, not only on reorg, so newly added local validators get preferences for an already-processed epoch (handleReorg -> reEmitLookahead). - Clear the validator-registration event queue at the Gloas fork so entries that didn't drain before the cutover don't linger. - Extract selfParticipatingIndices and evictEpochsBefore helpers shared by the proposer-preferences and PTC handlers. --- operator/duties/base_handler.go | 19 ++ operator/duties/proposer_preferences.go | 27 +- operator/duties/proposer_preferences_test.go | 7 +- operator/duties/ptc_attestation.go | 12 +- operator/duties/validator_registration.go | 2 + .../v2/ssv/runner/proposer_preferences.go | 245 ++++++++++++++---- .../ssv/runner/proposer_preferences_test.go | 85 +++++- 7 files changed, 314 insertions(+), 83 deletions(-) diff --git a/operator/duties/base_handler.go b/operator/duties/base_handler.go index efbbe826ff..70d485ced5 100644 --- a/operator/duties/base_handler.go +++ b/operator/duties/base_handler.go @@ -92,3 +92,22 @@ func (h *baseHandler) atLastSlotOfCurrentEpoch(currentSlot phase0.Slot) bool { func (h *baseHandler) atLastSlotOrPastCurrentPeriod(currentSlot phase0.Slot, currentPeriod uint64) bool { return currentSlot >= h.netCfg.LastActionableSlotOfSyncPeriod(currentPeriod) } + +// selfParticipatingIndices returns the indices of this node's validators participating in the epoch. +func (h *baseHandler) selfParticipatingIndices(epoch phase0.Epoch) []phase0.ValidatorIndex { + shares := h.validatorProvider.SelfParticipatingValidators(epoch) + indices := make([]phase0.ValidatorIndex, 0, len(shares)) + for _, share := range shares { + indices = append(indices, share.ValidatorIndex) + } + return indices +} + +// evictEpochsBefore drops entries for epochs earlier than before, bounding a per-epoch cache. +func evictEpochsBefore[V any](cache map[phase0.Epoch]V, before phase0.Epoch) { + for epoch := range cache { + if epoch < before { + delete(cache, epoch) + } + } +} diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index e43b965e2a..6483d0af48 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -55,18 +55,21 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.emitForTick(ctx, slot) case <-h.indicesChangeCh: + h.reEmitLookahead("indices change") case <-h.reorgEventsCh: - h.handleReorg() + h.reEmitLookahead("reorg") } } } -// handleReorg drops the emitted-epoch markers after a duty-dependent-root change so the next tick -// re-fetches and re-emits the lookahead's preferences. Because dependent_root is part of the gossip -// tuple (SIP #94 §5), the re-emission is a distinct preference, not a replacement of the prior one. -func (h *ProposerPreferencesHandler) handleReorg() { - h.logger.Debug("🔀 reorg: re-emitting proposer preferences on next tick") +// reEmitLookahead drops the emitted-epoch markers so the next tick re-fetches and re-emits the +// lookahead's preferences — after a reorg (new dependent_root) or a validator-set change (new local +// validators that missed an already-processed epoch). Per SIP #94 §5 a changed dependent_root yields a +// distinct gossip tuple, not a replacement; re-emitting an unchanged tuple is harmless (gossip keeps +// only the first). +func (h *ProposerPreferencesHandler) reEmitLookahead(reason string) { + h.logger.Debug("🔀 re-emitting proposer preferences on next tick", zap.String("reason", reason)) clear(h.processed) } @@ -95,11 +98,7 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha return } - shares := h.validatorProvider.SelfParticipatingValidators(epoch) - indices := make([]phase0.ValidatorIndex, 0, len(shares)) - for _, share := range shares { - indices = append(indices, share.ValidatorIndex) - } + indices := h.selfParticipatingIndices(epoch) if len(indices) == 0 { return // no local validators yet; retry on the next tick } @@ -138,9 +137,5 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha // evictOutdated drops processed-epoch markers for epochs before the current one. func (h *ProposerPreferencesHandler) evictOutdated(currentEpoch phase0.Epoch) { - for epoch := range h.processed { - if epoch < currentEpoch { - delete(h.processed, epoch) - } - } + evictEpochsBefore(h.processed, currentEpoch) } diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index fe0d7c76a2..760d613178 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -166,15 +166,16 @@ func TestProposerPreferencesHandler_emitForTick(t *testing.T) { } } -// A reorg drops the emitted-epoch markers so the next tick re-fetches and re-emits the lookahead. -func TestProposerPreferencesHandler_handleReorg_clearsProcessed(t *testing.T) { +// A reorg or indices change drops the emitted-epoch markers so the next tick re-fetches and re-emits +// the lookahead. +func TestProposerPreferencesHandler_reEmitLookahead_clearsProcessed(t *testing.T) { h := NewProposerPreferencesHandler() h.logger = zap.NewNop() for _, e := range []phase0.Epoch{100, 101} { h.processed[e] = struct{}{} } - h.handleReorg() + h.reEmitLookahead("test") require.Empty(t, h.processed) } diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go index e2fc306268..e390bb2d9a 100644 --- a/operator/duties/ptc_attestation.go +++ b/operator/duties/ptc_attestation.go @@ -77,11 +77,7 @@ func (h *PTCAttestationHandler) fetchDuties(ctx context.Context, epoch phase0.Ep return } - shares := h.validatorProvider.SelfParticipatingValidators(epoch) - indices := make([]phase0.ValidatorIndex, 0, len(shares)) - for _, share := range shares { - indices = append(indices, share.ValidatorIndex) - } + indices := h.selfParticipatingIndices(epoch) if len(indices) == 0 { return } @@ -117,9 +113,5 @@ func (h *PTCAttestationHandler) scheduleExecution(ctx context.Context, slot phas // evictOutdated drops cached duties for epochs before the current one. func (h *PTCAttestationHandler) evictOutdated(currentEpoch phase0.Epoch) { - for epoch := range h.duties { - if epoch < currentEpoch { - delete(h.duties, epoch) - } - } + evictEpochsBefore(h.duties, currentEpoch) } diff --git a/operator/duties/validator_registration.go b/operator/duties/validator_registration.go index b556aaf44e..53bce515f6 100644 --- a/operator/duties/validator_registration.go +++ b/operator/duties/validator_registration.go @@ -209,7 +209,9 @@ func (h *ValidatorRegistrationHandler) processExecution(ctx context.Context, epo defer span.End() // Validator registration is deprecated at the Gloas fork — superseded by proposer preferences (§5). + // Drop any entries that didn't drain before the fork; nothing more is enqueued past it. if h.netCfg.IsGloas(epoch) { + h.eventQueue = nil return } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 0ee725e828..7faee670d8 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -22,32 +22,24 @@ import ( var _ Runner = (*ProposerPreferencesRunner)(nil) -// ProposerPreferencesRunner runs the Gloas (ePBS) proposer-preferences duty (SIP #94 §5): one duty -// per upcoming proposal slot, broadcasting the fee recipient and target gas limit builders must -// honor. Like the PTC runner it has no consensus or pre-consensus negotiation — each operator builds -// the preference from its own beacon node and a per-validator signature reconstructs only once a -// threshold of operators converged on byte-identical preferences (honest convergence, not consensus). +// ProposerPreferencesRunner is the registered proposer-preferences runner for a validator (SIP #94 +// §5). Unlike the single-duty runners (PTC, validator registration, ...), a validator can hold several +// upcoming proposal slots in the lookahead at once, and because preference messages route by +// MessageID (validator + role, with the proposal slot carried inside the message) they all arrive +// here. A single per-(validator, role) runner state can only hold one slot, so this dispatches each +// proposal slot to its own proposerPreferencesSlotRunner; otherwise concurrently-emitted slots would +// overwrite or reject one another. // -// duty.Slot is the proposal slot the preference targets, which is also the slot carried on the -// partial-signature message and the slot whose epoch fixes the signing domain — keeping all three in -// step with the base runner's slot checks. The duty executes (emits) earlier, near the current slot, -// so the message rides a future slot; permitting that is the message-validation layer's job, not the -// runner's. +// The embedded BaseRunner provides only the static Runner surface (role, share, persistence); the +// per-slot sub-runners own the actual duty state, so HasRunningDuty is overridden to aggregate them. type ProposerPreferencesRunner struct { *BaseRunner - beacon beacon.BeaconNode - network protocolp2p.Network - signer ekm.BeaconSigner - operatorSigner ssvtypes.OperatorSigner - feeRecipientProvider feeRecipientProvider - gasLimit uint64 + opts ProposerPreferencesRunnerOptions - // proposerPreferences is the operator's frozen observation for this proposal slot: the preference - // it built (including the dependent_root its own beacon node reported). Incoming partial signatures - // are validated and aggregated against exactly this object's signing root; nil means the duty has - // not executed yet. - proposerPreferences *gloas.ProposerPreferences + // bySlot holds one sub-runner per concurrently-active proposal slot. Accessed only from the + // validator's single message-processing goroutine. + bySlot map[phase0.Slot]*proposerPreferencesSlotRunner } // ProposerPreferencesRunnerOptions bundles the dependencies required by NewProposerPreferencesRunner. @@ -69,6 +61,169 @@ func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner NetworkConfig: opts.NetworkConfig, Share: opts.Share, }, + opts: opts, + bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, + }, nil +} + +func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + + r.evictPastSlots() + + // One sub-runner per proposal slot; a re-emission for the same slot (e.g. after a reorg) replaces + // the prior one so it freezes the new dependent_root. + sub := newProposerPreferencesSlotRunner(r.opts) + r.bySlot[validatorDuty.DutySlot()] = sub + return sub.StartNewDuty(ctx, logger, duty, quorum) +} + +func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + sub, ok := r.bySlot[signedMsg.Slot] + if !ok { + // No sub-runner for this proposal slot — it hasn't executed here yet, or it already concluded + // and was evicted. Retryable so a slightly-early peer message lands once the duty starts. + return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) + } + return sub.ProcessPreConsensus(ctx, logger, signedMsg) +} + +func (r *ProposerPreferencesRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { + return fmt.Errorf("no consensus phase for proposer preferences") +} + +func (r *ProposerPreferencesRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return fmt.Errorf("no post-consensus phase for proposer preferences") +} + +// HasRunningDuty reports whether any proposal slot is still running (the embedded BaseRunner's own +// state is unused — the sub-runners hold the duties). +func (r *ProposerPreferencesRunner) HasRunningDuty() bool { + for _, sub := range r.bySlot { + if sub.HasRunningDuty() { + return true + } + } + return false +} + +// evictPastSlots drops sub-runners whose proposal slot has passed; the preference is moot once the +// proposal slot arrives, and convergence completes well before it. +func (r *ProposerPreferencesRunner) evictPastSlots() { + current := r.NetworkConfig.EstimatedCurrentSlot() + for slot := range r.bySlot { + if slot < current { + delete(r.bySlot, slot) + } + } +} + +func (r *ProposerPreferencesRunner) GetNetwork() protocolp2p.Network { return r.opts.Network } + +func (r *ProposerPreferencesRunner) GetBeaconNode() beacon.BeaconNode { return r.opts.Beacon } + +func (r *ProposerPreferencesRunner) GetSigner() ekm.BeaconSigner { return r.opts.Signer } + +func (r *ProposerPreferencesRunner) GetOperatorSigner() ssvtypes.OperatorSigner { + return r.opts.OperatorSigner +} + +// expectedPreConsensusRootsAndDomain / expectedPostConsensusRootsAndDomain / executeDuty are part of +// the Runner interface but run on the per-slot sub-runners, never the dispatcher. +func (r *ProposerPreferencesRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, spectypes.DomainError, fmt.Errorf("proposer preferences dispatcher has no frozen preference") +} + +func (r *ProposerPreferencesRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for proposer preferences") +} + +func (r *ProposerPreferencesRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { + return fmt.Errorf("proposer preferences dispatcher does not execute duties directly") +} + +// Only the static BaseRunner is persisted; the per-slot sub-runners are transient (re-emitted by the +// duty handler). +func (r *ProposerPreferencesRunner) MarshalJSON() ([]byte, error) { + type proposerPreferencesRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + return json.Marshal(&proposerPreferencesRunnerJSON{BaseRunner: r.BaseRunner}) +} + +func (r *ProposerPreferencesRunner) UnmarshalJSON(data []byte) error { + type proposerPreferencesRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + aux := &proposerPreferencesRunnerJSON{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if aux.BaseRunner == nil { + return fmt.Errorf("missing BaseRunner") + } + r.BaseRunner = aux.BaseRunner + if r.bySlot == nil { + r.bySlot = map[phase0.Slot]*proposerPreferencesSlotRunner{} + } + return nil +} + +func (r *ProposerPreferencesRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *ProposerPreferencesRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *ProposerPreferencesRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + return [32]byte{}, fmt.Errorf("could not encode ProposerPreferencesRunner: %w", err) + } + return sha256.Sum256(marshaledRoot), nil +} + +var _ Runner = (*proposerPreferencesSlotRunner)(nil) + +// proposerPreferencesSlotRunner runs the Gloas (ePBS) proposer-preferences duty for one proposal slot +// (SIP #94 §5), driven by ProposerPreferencesRunner (one per concurrently-active slot). Like the PTC +// runner it has no consensus or pre-consensus negotiation — each operator builds the preference from +// its own beacon node and a per-validator signature reconstructs only once a threshold of operators +// converged on byte-identical preferences (honest convergence, not consensus). +// +// duty.Slot is the proposal slot the preference targets, which is also the slot carried on the +// partial-signature message and the slot whose epoch fixes the signing domain — keeping all three in +// step with the base runner's slot checks. The duty executes (emits) earlier, near the current slot, +// so the message rides a future slot; permitting that is the message-validation layer's job. +type proposerPreferencesSlotRunner struct { + *BaseRunner + + beacon beacon.BeaconNode + network protocolp2p.Network + signer ekm.BeaconSigner + operatorSigner ssvtypes.OperatorSigner + feeRecipientProvider feeRecipientProvider + gasLimit uint64 + + // proposerPreferences is the operator's frozen observation for this proposal slot: the preference + // it built (including the dependent_root its own beacon node reported). Incoming partial signatures + // are validated and aggregated against exactly this object's signing root; nil means the duty has + // not executed yet. + proposerPreferences *gloas.ProposerPreferences +} + +func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { + return &proposerPreferencesSlotRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleProposerPreferences, + NetworkConfig: opts.NetworkConfig, + Share: opts.Share, + }, beacon: opts.Beacon, network: opts.Network, @@ -76,10 +231,10 @@ func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner operatorSigner: opts.OperatorSigner, feeRecipientProvider: opts.FeeRecipientProvider, gasLimit: opts.GasLimit, - }, nil + } } -func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { +func (r *proposerPreferencesSlotRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { validatorDuty, err := validatorDutyFromDuty(duty) if err != nil { return err @@ -89,10 +244,10 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) } -func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { +func (r *proposerPreferencesSlotRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { hasQuorum, roots, err := r.basePreConsensusMsgProcessing(ctx, logger, r, signedMsg) if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { - // The runner is reused across duties, so a late message for a concluded duty is retryable. + // A late message for a concluded slot is retryable (the sub-runner lingers until evicted). err = NewRetryableError(err) } if err != nil { @@ -149,26 +304,26 @@ func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, log return nil } -func (r *ProposerPreferencesRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { +func (r *proposerPreferencesSlotRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { return fmt.Errorf("no consensus phase for proposer preferences") } -func (r *ProposerPreferencesRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { +func (r *proposerPreferencesSlotRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { return fmt.Errorf("no post-consensus phase for proposer preferences") } -func (r *ProposerPreferencesRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { +func (r *proposerPreferencesSlotRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { if r.proposerPreferences == nil { return nil, spectypes.DomainError, fmt.Errorf("no frozen proposer preferences") } return []ssz.HashRoot{r.proposerPreferences}, phase0.DomainType(spectypes.DomainProposerPreferences), nil } -func (r *ProposerPreferencesRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { +func (r *proposerPreferencesSlotRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for proposer preferences") } -func (r *ProposerPreferencesRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { +func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { validatorDuty, err := validatorDutyFromDuty(duty) if err != nil { return err @@ -210,7 +365,7 @@ func (r *ProposerPreferencesRunner) executeDuty(ctx context.Context, logger *zap // view: fee recipient and target gas limit from validator config (matching validator registration), // and the dependent_root of the proposer duties for the proposal slot's epoch — the seed that fixed // this proposal assignment (SIP #94 §5), fetched per-operator so convergence is over identical roots. -func (r *ProposerPreferencesRunner) buildProposerPreferences(ctx context.Context, proposalSlot phase0.Slot) (*gloas.ProposerPreferences, error) { +func (r *proposerPreferencesSlotRunner) buildProposerPreferences(ctx context.Context, proposalSlot phase0.Slot) (*gloas.ProposerPreferences, error) { validatorPubKey := r.GetShare().ValidatorPubKey feeRecipient, err := r.feeRecipientProvider.GetFeeRecipient(validatorPubKey) @@ -238,29 +393,29 @@ func (r *ProposerPreferencesRunner) buildProposerPreferences(ctx context.Context }, nil } -func (r *ProposerPreferencesRunner) GetNetwork() protocolp2p.Network { return r.network } +func (r *proposerPreferencesSlotRunner) GetNetwork() protocolp2p.Network { return r.network } -func (r *ProposerPreferencesRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } +func (r *proposerPreferencesSlotRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } -func (r *ProposerPreferencesRunner) GetSigner() ekm.BeaconSigner { return r.signer } +func (r *proposerPreferencesSlotRunner) GetSigner() ekm.BeaconSigner { return r.signer } -func (r *ProposerPreferencesRunner) GetOperatorSigner() ssvtypes.OperatorSigner { +func (r *proposerPreferencesSlotRunner) GetOperatorSigner() ssvtypes.OperatorSigner { return r.operatorSigner } // Only BaseRunner is persisted; the frozen observation is transient per-duty state. -func (r *ProposerPreferencesRunner) MarshalJSON() ([]byte, error) { - type proposerPreferencesRunnerJSON struct { +func (r *proposerPreferencesSlotRunner) MarshalJSON() ([]byte, error) { + type proposerPreferencesSlotRunnerJSON struct { BaseRunner *BaseRunner `json:"BaseRunner"` } - return json.Marshal(&proposerPreferencesRunnerJSON{BaseRunner: r.BaseRunner}) + return json.Marshal(&proposerPreferencesSlotRunnerJSON{BaseRunner: r.BaseRunner}) } -func (r *ProposerPreferencesRunner) UnmarshalJSON(data []byte) error { - type proposerPreferencesRunnerJSON struct { +func (r *proposerPreferencesSlotRunner) UnmarshalJSON(data []byte) error { + type proposerPreferencesSlotRunnerJSON struct { BaseRunner *BaseRunner `json:"BaseRunner"` } - aux := &proposerPreferencesRunnerJSON{} + aux := &proposerPreferencesSlotRunnerJSON{} if err := json.Unmarshal(data, aux); err != nil { return err } @@ -271,18 +426,18 @@ func (r *ProposerPreferencesRunner) UnmarshalJSON(data []byte) error { return nil } -func (r *ProposerPreferencesRunner) Encode() ([]byte, error) { +func (r *proposerPreferencesSlotRunner) Encode() ([]byte, error) { return json.Marshal(r) } -func (r *ProposerPreferencesRunner) Decode(data []byte) error { +func (r *proposerPreferencesSlotRunner) Decode(data []byte) error { return json.Unmarshal(data, r) } -func (r *ProposerPreferencesRunner) GetRoot() ([32]byte, error) { +func (r *proposerPreferencesSlotRunner) GetRoot() ([32]byte, error) { marshaledRoot, err := r.Encode() if err != nil { - return [32]byte{}, fmt.Errorf("could not encode ProposerPreferencesRunner: %w", err) + return [32]byte{}, fmt.Errorf("could not encode proposerPreferencesSlotRunner: %w", err) } return sha256.Sum256(marshaledRoot), nil } diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index cc699725a2..f3a920b931 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -2,17 +2,26 @@ package runner import ( "context" + "fmt" "testing" + "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) +type errFeeRecipientProvider struct{} + +func (errFeeRecipientProvider) GetFeeRecipient(spectypes.ValidatorPK) (bellatrix.ExecutionAddress, error) { + return bellatrix.ExecutionAddress{}, fmt.Errorf("no fee recipient") +} + func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { _, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{}) require.Error(t, err) @@ -26,11 +35,76 @@ func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).BaseRunner.RunnerRoleType) } +// A validator can hold several lookahead proposal slots at once; the dispatcher gives each its own +// sub-runner instead of the single-runner state overwriting/rejecting all but one. +func TestProposerPreferencesRunner_ConcurrentSlotsTracked(t *testing.T) { + netCfg := networkconfig.TestNetwork + opts := ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: netCfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {ValidatorIndex: 0}}, + }, + FeeRecipientProvider: errFeeRecipientProvider{}, // executeDuty fails fast; we assert the per-slot dispatch + } + r, err := NewProposerPreferencesRunner(opts) + require.NoError(t, err) + disp := r.(*ProposerPreferencesRunner) + + current := netCfg.EstimatedCurrentSlot() + for _, slot := range []phase0.Slot{current + 10, current + 20} { + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleProposerPreferences, ValidatorIndex: 0, Slot: slot} + require.NoError(t, disp.StartNewDuty(context.Background(), zap.NewNop(), duty, 1)) + } + + require.Len(t, disp.bySlot, 2) // both slots tracked; neither overwrote or rejected the other +} + +// evictPastSlots drops sub-runners whose proposal slot has already passed. +func TestProposerPreferencesRunner_evictPastSlots(t *testing.T) { + netCfg := networkconfig.TestNetwork + r, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: netCfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {}}, + }, + }) + require.NoError(t, err) + disp := r.(*ProposerPreferencesRunner) + + current := netCfg.EstimatedCurrentSlot() + disp.bySlot[current-1] = newProposerPreferencesSlotRunner(disp.opts) + disp.bySlot[current+10] = newProposerPreferencesSlotRunner(disp.opts) + + disp.evictPastSlots() + + require.NotContains(t, disp.bySlot, current-1) + require.Contains(t, disp.bySlot, current+10) +} + +// An incoming partial signature for a slot with no sub-runner is retryable, not a hard error. +func TestProposerPreferencesRunner_ProcessPreConsensus_unknownSlot(t *testing.T) { + r, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {}}}, + }) + require.NoError(t, err) + + err = r.ProcessPreConsensus(context.Background(), zap.NewNop(), &spectypes.PartialSignatureMessages{Slot: 999}) + require.Error(t, err) + require.True(t, IsRetryable(err)) +} + +// Proposer preferences have no consensus or post-consensus phase; those entry points must reject. +func TestProposerPreferencesRunner_NoConsensusPhases(t *testing.T) { + r := &ProposerPreferencesRunner{} + require.Error(t, r.ProcessConsensus(context.Background(), zap.NewNop(), nil)) + require.Error(t, r.ProcessPostConsensus(context.Background(), zap.NewNop(), nil)) +} + // The runner validates and aggregates incoming partial signatures against its own frozen preference: // there is no expected root before executeDuty has built and frozen one, and afterwards it is exactly // that preference's root under DomainProposerPreferences. -func TestProposerPreferencesRunner_ExpectedPreConsensusRootsAndDomain(t *testing.T) { - r := &ProposerPreferencesRunner{} +func TestProposerPreferencesSlotRunner_ExpectedPreConsensusRootsAndDomain(t *testing.T) { + r := &proposerPreferencesSlotRunner{} _, _, err := r.expectedPreConsensusRootsAndDomain() require.Error(t, err) @@ -42,10 +116,3 @@ func TestProposerPreferencesRunner_ExpectedPreConsensusRootsAndDomain(t *testing require.Equal(t, []ssz.HashRoot{prefs}, roots) require.Equal(t, phase0.DomainType(spectypes.DomainProposerPreferences), domain) } - -// Proposer preferences have no consensus or post-consensus phase; those entry points must reject. -func TestProposerPreferencesRunner_NoConsensusPhases(t *testing.T) { - r := &ProposerPreferencesRunner{} - require.Error(t, r.ProcessConsensus(context.Background(), zap.NewNop(), nil)) - require.Error(t, r.ProcessPostConsensus(context.Background(), zap.NewNop(), nil)) -} From 7880f128113c8a96324b79353a02a2ec55b9db35 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 13:14:08 +0300 Subject: [PATCH 028/150] =?UTF-8?q?gloas:=20dedup=20ProposerPreferences=20?= =?UTF-8?q?dependent=5Froot=20fetches=20(SIP=20#94=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the second ProposerPreferences review round. - Collapse the concurrent per-epoch dependent_root GETs (one per local proposing validator) into a single request via a singleflight group on the goclient. Deliberately not TTL-cached, so a reorg re-emission still observes a fresh dependent_root. - Strengthen the multi-slot regression test to the exact decreasing-order case (a higher proposal slot started first) and assert the lower slot survives — the precise scenario the old single runner dropped. --- beacon/goclient/goclient.go | 7 ++++ beacon/goclient/proposer_preferences.go | 42 +++++++++++-------- .../ssv/runner/proposer_preferences_test.go | 12 ++++-- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index c33fed7155..d44f07d8cb 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -160,6 +160,13 @@ type GoClient struct { // committeesCache caches Beacon committees by epoch to avoid repeated fetching committeesCache *ttlcache.Cache[phase0.Epoch, []*eth2apiv1.BeaconCommittee] + // proposerDutiesDependentRootInflight collapses the per-epoch dependent_root GETs that the + // proposer-preferences runners issue concurrently — one per local proposing validator in the epoch + // (SIP #94 §5) — into a single request. Deliberately not TTL-cached: a reorg re-emission must + // observe a fresh dependent_root, and the duplication removed here is a same-instant burst across + // the epoch's proposers, not reuse over time. + proposerDutiesDependentRootInflight singleflight.Group[phase0.Epoch, phase0.Root] + commonTimeout time.Duration longTimeout time.Duration diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 920d281270..f4f9b35a48 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -18,25 +18,31 @@ import ( // seed; go-eth2-client drops the field, so this is a raw-HTTP fetch (the same interim surface as the // PTC endpoints) until the fork exposes it. func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { - return firstClientResult(ctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { - var resp struct { - DependentRoot string `json:"dependent_root"` - } - url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) - if err := ptcDo(ctx, ptcHTTPClient, http.MethodGet, url, nil, nil, &resp); err != nil { - return phase0.Root{}, err - } - raw, err := hex.DecodeString(strings.TrimPrefix(resp.DependentRoot, "0x")) - if err != nil { - return phase0.Root{}, fmt.Errorf("decode dependent_root %q: %w", resp.DependentRoot, err) - } - var root phase0.Root - if len(raw) != len(root) { - return phase0.Root{}, fmt.Errorf("dependent_root: expected %d bytes, got %d", len(root), len(raw)) - } - copy(root[:], raw) - return root, nil + // Several proposer-preferences runners (one per local proposing validator in the epoch) request the + // same epoch's dependent_root concurrently; collapse that burst into a single GET. Not TTL-cached so + // a reorg re-emission still observes a fresh root. + root, err, _ := gc.proposerDutiesDependentRootInflight.Do(epoch, func() (phase0.Root, error) { + return firstClientResult(ctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { + var resp struct { + DependentRoot string `json:"dependent_root"` + } + url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) + if err := ptcDo(ctx, ptcHTTPClient, http.MethodGet, url, nil, nil, &resp); err != nil { + return phase0.Root{}, err + } + raw, err := hex.DecodeString(strings.TrimPrefix(resp.DependentRoot, "0x")) + if err != nil { + return phase0.Root{}, fmt.Errorf("decode dependent_root %q: %w", resp.DependentRoot, err) + } + var root phase0.Root + if len(raw) != len(root) { + return phase0.Root{}, fmt.Errorf("dependent_root: expected %d bytes, got %d", len(root), len(raw)) + } + copy(root[:], raw) + return root, nil + }) }) + return root, err } // SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5). diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index f3a920b931..b4996df265 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -35,8 +35,9 @@ func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).BaseRunner.RunnerRoleType) } -// A validator can hold several lookahead proposal slots at once; the dispatcher gives each its own -// sub-runner instead of the single-runner state overwriting/rejecting all but one. +// Regression for the monotonic ShouldProcessNonBeaconDuty reject (runner.go): a validator can hold +// several lookahead proposal slots at once, and a HIGHER slot started first must not cause a +// subsequently started LOWER slot to be dropped. The dispatcher gives each slot its own sub-runner. func TestProposerPreferencesRunner_ConcurrentSlotsTracked(t *testing.T) { netCfg := networkconfig.TestNetwork opts := ProposerPreferencesRunnerOptions{ @@ -50,13 +51,16 @@ func TestProposerPreferencesRunner_ConcurrentSlotsTracked(t *testing.T) { require.NoError(t, err) disp := r.(*ProposerPreferencesRunner) + // Decreasing order is the exact case that broke the single runner: the higher slot, started first, + // made the base runner reject the lower one as "already passed". current := netCfg.EstimatedCurrentSlot() - for _, slot := range []phase0.Slot{current + 10, current + 20} { + for _, slot := range []phase0.Slot{current + 20, current + 10} { duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleProposerPreferences, ValidatorIndex: 0, Slot: slot} require.NoError(t, disp.StartNewDuty(context.Background(), zap.NewNop(), duty, 1)) } - require.Len(t, disp.bySlot, 2) // both slots tracked; neither overwrote or rejected the other + require.Len(t, disp.bySlot, 2) // both slots tracked, neither overwrote/rejected the other + require.Contains(t, disp.bySlot, current+10) // the lower slot, started second, survived } // evictPastSlots drops sub-runners whose proposal slot has already passed. From 3e568034cc86c17905cefed348deec57169977ca Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 13:30:19 +0300 Subject: [PATCH 029/150] =?UTF-8?q?gloas:=20add=20NewGloasVoteChecker=20fo?= =?UTF-8?q?r=20=C2=A72=20attestations=20(SIP=20#94=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gloas committee-runner consensus-value check. Mirrors voteChecker (decode, source= bv.Target.Epoch { + return spectypes.NewError(spectypes.AttestationSourceNotLessThanTargetErrorCode, "attestation data source >= target") + } + + // SIP #94 §2: AttestationDataIndex carries the attester's payload-status view (0 = EMPTY, + // 1 = FULL), so it must be 0 or 1. The same-slot "index = 0" rule is BN/gossip-enforced — it needs + // the attested block's slot — so it is not checked here. + if bv.AttestationDataIndex > 1 { + return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "gloas attestation data index out of range") + } + + attestationData := &phase0.AttestationData{ + Slot: v.slot, + // The decided payload-status index — exactly what constructAttestationData will sign — so the + // slashing pre-check sees the same data that gets signed. SSV's protection is epoch-only, so the + // index is inert to the comparison either way. + Index: bv.AttestationDataIndex, + BeaconBlockRoot: bv.BlockRoot, + Source: bv.Source, + Target: bv.Target, + } + + for _, sharePublicKey := range v.sharePublicKeys { + if err := v.signer.IsAttestationSlashable(sharePublicKey, attestationData); err != nil { + return err + } + } + + // Epoch-only majority-fork protection (sips/majority_fork_protection.md), as in NewVoteChecker. + if bv.Source.Epoch != v.expectedVote.Source.Epoch { + return fmt.Errorf("unexpected source epoch %v, expected %v", bv.Source.Epoch, v.expectedVote.Source.Epoch) + } + if bv.Target.Epoch != v.expectedVote.Target.Epoch { + return fmt.Errorf("unexpected target epoch %v, expected %v", bv.Target.Epoch, v.expectedVote.Target.Epoch) + } + + return nil +} + type aggregatorCommitteeChecker struct{} func NewAggregatorCommitteeChecker() ValueChecker { diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index ff512dff7c..05a745f959 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -1,11 +1,15 @@ package ssv import ( + "fmt" "testing" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" ) // TestVoteCheckerSourceTargetEpoch pins the behavior of the source/target epoch check at @@ -106,3 +110,78 @@ func TestValidateNoDuplicateAggregatorCommittee(t *testing.T) { require.ErrorContains(t, validateNoDuplicateAggregatorCommittee(cd), "duplicate contributor") }) } + +// fakeSlashingSigner implements ekm.BeaconSigner for value-check tests. Only IsAttestationSlashable is +// exercised; the embedded nil interface panics if any other method is called, which surfaces an +// unexpected dependency rather than hiding it. +type fakeSlashingSigner struct { + ekm.BeaconSigner + slashable error +} + +func (f fakeSlashingSigner) IsAttestationSlashable(phase0.BLSPubKey, *phase0.AttestationData) error { + return f.slashable +} + +func gloasVote(source, target phase0.Epoch, index phase0.CommitteeIndex) *gloas.GloasBeaconVote { + return &gloas.GloasBeaconVote{ + BlockRoot: phase0.Root{0x01}, + Source: &phase0.Checkpoint{Epoch: source}, + Target: &phase0.Checkpoint{Epoch: target}, + AttestationDataIndex: index, + } +} + +func encodeGloasVote(t *testing.T, v *gloas.GloasBeaconVote) []byte { + t.Helper() + b, err := v.Encode() + require.NoError(t, err) + return b +} + +func newGloasChecker(signer ekm.BeaconSigner, expected *gloas.GloasBeaconVote) ValueChecker { + return NewGloasVoteChecker(signer, 64, []phase0.BLSPubKey{{}}, expected) +} + +// Both payload-status indices (0 = EMPTY, 1 = FULL) pass when source < target, the epochs match the +// expected vote, and the attestation is not slashable. +func TestGloasVoteChecker_Valid(t *testing.T) { + for _, index := range []phase0.CommitteeIndex{0, 1} { + expected := gloasVote(1, 2, index) + checker := newGloasChecker(fakeSlashingSigner{}, expected) + require.NoError(t, checker.CheckValue(encodeGloasVote(t, gloasVote(1, 2, index)))) + } +} + +// The one Gloas-specific rule: AttestationDataIndex outside {0, 1} is rejected. +func TestGloasVoteChecker_IndexOutOfRange(t *testing.T) { + expected := gloasVote(1, 2, 0) + checker := newGloasChecker(fakeSlashingSigner{}, expected) + require.Error(t, checker.CheckValue(encodeGloasVote(t, gloasVote(1, 2, 2)))) +} + +func TestGloasVoteChecker_SourceNotBeforeTarget(t *testing.T) { + expected := gloasVote(2, 2, 0) + checker := newGloasChecker(fakeSlashingSigner{}, expected) + require.Error(t, checker.CheckValue(encodeGloasVote(t, gloasVote(2, 2, 0)))) +} + +// Epoch-only majority-fork protection: a vote whose target epoch differs from the operator's expected +// vote is rejected (the index, by contrast, is trusted from the leader and not compared). +func TestGloasVoteChecker_EpochMismatch(t *testing.T) { + expected := gloasVote(1, 2, 0) + checker := newGloasChecker(fakeSlashingSigner{}, expected) + require.Error(t, checker.CheckValue(encodeGloasVote(t, gloasVote(1, 3, 0)))) +} + +func TestGloasVoteChecker_Slashable(t *testing.T) { + expected := gloasVote(1, 2, 0) + checker := newGloasChecker(fakeSlashingSigner{slashable: fmt.Errorf("slashable")}, expected) + require.Error(t, checker.CheckValue(encodeGloasVote(t, gloasVote(1, 2, 0)))) +} + +func TestGloasVoteChecker_DecodeError(t *testing.T) { + expected := gloasVote(1, 2, 0) + checker := newGloasChecker(fakeSlashingSigner{}, expected) + require.Error(t, checker.CheckValue([]byte{0x00, 0x01, 0x02})) // too short for a 120-byte vote +} From ec85f132e2e53d898c75b0e1606989f08e8036cf Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 14:25:57 +0300 Subject: [PATCH 030/150] =?UTF-8?q?gloas:=20wire=20GloasBeaconVote=20into?= =?UTF-8?q?=20the=20committee=20runner=20(SIP=20#94=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Gloas slots the committee runner now agrees on GloasBeaconVote (the BeaconVote fields plus the BN-supplied attestation index) and signs attestations with the decided payload-status index, instead of a plain BeaconVote. The QBFT plumbing is generic over spectypes.Encoder / ssv.ValueChecker, so only the value type and its checker (NewGloasVoteChecker) differ per fork. Decode is made fork-aware at every seam: the consensus decode prototype, the post-consensus expected-roots decode, and — caught in review — the post-consensus partial-sig validation in runner_validations.go, which decoded BeaconVote and would otherwise reject every Gloas post-consensus signature so attestations never aggregate. decidedAttestationVote (replacing beaconVoteFromEncoder) extracts the common BeaconVote plus the index as a *phase0.CommitteeIndex — non-nil only on Gloas — which constructAttestationData uses to override the Electra index=0. The fork gate is IsGloas (BeaconForkAtEpoch caps at Fulu). The sync-committee path is untouched (it reads only BlockRoot). Message validation needs no change: it hashes raw FullData and never decodes the vote type. --- protocol/v2/ssv/runner/committee.go | 81 +++++++++++++------ protocol/v2/ssv/runner/committee_test.go | 13 ++- protocol/v2/ssv/runner/runner_validations.go | 15 +++- protocol/v2/ssv/runner/type_assertions.go | 38 ++++++--- .../v2/ssv/runner/type_assertions_test.go | 35 ++++++-- 5 files changed, 132 insertions(+), 50 deletions(-) diff --git a/protocol/v2/ssv/runner/committee.go b/protocol/v2/ssv/runner/committee.go index b245171c7c..0e97dc30be 100644 --- a/protocol/v2/ssv/runner/committee.go +++ b/protocol/v2/ssv/runner/committee.go @@ -33,6 +33,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" "github.com/ssvlabs/ssv/protocol/v2/ssv" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type CommitteeDutyGuard interface { @@ -220,7 +221,16 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg span := trace.SpanFromContext(ctx) span.AddEvent("processing QBFT consensus msg") - decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, msg, &spectypes.BeaconVote{}) + + // The decided value is a GloasBeaconVote (which carries the attestation index) on Gloas slots, a + // plain BeaconVote before. Pick the decode prototype from the running duty's fork; a message with no + // running duty cannot decide, so the default is harmless there. + decidedPrototype := spectypes.Encoder(&spectypes.BeaconVote{}) + if committeeDuty, dutyErr := r.currentCommitteeDuty(); dutyErr == nil && r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(committeeDuty.DutySlot())) { + decidedPrototype = &gloas.GloasBeaconVote{} + } + + decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, msg, decidedPrototype) if err != nil { return fmt.Errorf("failed processing consensus message: %w", err) } @@ -272,7 +282,7 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg blockedAttesterDuties atomic.Uint32 ) - beaconVote, err := beaconVoteFromEncoder(decidedValue) + beaconVote, gloasAttestationIndex, err := decidedAttestationVote(decidedValue) if err != nil { return fmt.Errorf("beacon vote: %w", err) } @@ -316,7 +326,7 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg switch validatorDuty.Type { case spectypes.BNRoleAttester: totalAttesterDuties.Add(1) - isAttesterDutyBlocked, partialSigMsg, err := r.signAttesterDuty(ctx, validatorDuty, beaconVote, version, logger) + isAttesterDutyBlocked, partialSigMsg, err := r.signAttesterDuty(ctx, validatorDuty, beaconVote, version, gloasAttestationIndex, logger) if err != nil { errCh <- fmt.Errorf("failed signing attestation data: %w", err) return @@ -452,6 +462,7 @@ func (r *CommitteeRunner) signAttesterDuty( validatorDuty *spectypes.ValidatorDuty, beaconVote *spectypes.BeaconVote, version spec.DataVersion, + gloasAttestationIndex *phase0.CommitteeIndex, logger *zap.Logger) (isBlocked bool, partialSig *spectypes.PartialSignatureMessage, err error) { // Reuse the existing span instead of generating new one to keep tracing-data lightweight. span := trace.SpanFromContext(ctx) @@ -467,7 +478,7 @@ func (r *CommitteeRunner) signAttesterDuty( return true, nil, nil } - attestationData := constructAttestationData(beaconVote, validatorDuty, version) + attestationData := constructAttestationData(beaconVote, validatorDuty, version, gloasAttestationIndex) span.AddEvent("signing beacon object") partialMsg, err := signBeaconObject( @@ -1032,16 +1043,23 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context if err != nil { return nil, nil, nil, fmt.Errorf("current committee duty: %w", err) } - beaconVoteData := r.State.DecidedValue - beaconVote := &spectypes.BeaconVote{} - if err := beaconVote.Decode(beaconVoteData); err != nil { - return nil, nil, nil, fmt.Errorf("could not decode beacon vote: %w", err) - } - slot := committeeDuty.DutySlot() epoch := r.NetworkConfig.EstimatedEpochAtSlot(slot) dataVersion, _ := r.NetworkConfig.ForkAtEpoch(epoch) + // Decode into the slot's fork prototype (GloasBeaconVote carries the attestation index on Gloas). + decidedVote := spectypes.Encoder(&spectypes.BeaconVote{}) + if r.NetworkConfig.IsGloas(epoch) { + decidedVote = &gloas.GloasBeaconVote{} + } + if err := decidedVote.Decode(r.State.DecidedValue); err != nil { + return nil, nil, nil, fmt.Errorf("could not decode beacon vote: %w", err) + } + beaconVote, gloasAttestationIndex, err := decidedAttestationVote(decidedVote) + if err != nil { + return nil, nil, nil, err + } + // Skips fall into two classes: guard invalidations are benign (the #2903 divergent-validator-sets // case — the duty is genuinely not this operator's to submit), while construction / domain-data / // signing-root failures mean a submission was missed. The distinction only matters when NOTHING @@ -1066,7 +1084,7 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context switch validatorDuty.Type { case spectypes.BNRoleAttester: // Attestation object - attestationData := constructAttestationData(beaconVote, validatorDuty, dataVersion) + attestationData := constructAttestationData(beaconVote, validatorDuty, dataVersion, gloasAttestationIndex) attestationResponse, err := specssv.ConstructVersionedAttestationWithoutSignature(attestationData, dataVersion, validatorDuty) if err != nil { logger.Debug("failed to construct attestation", zap.Error(err)) @@ -1152,20 +1170,31 @@ func (r *CommitteeRunner) executeDuty(ctx context.Context, logger *zap.Logger, d logger.Debug(attestationDataFetchedEvent, fields.Took(time.Since(start))) span.AddEvent(attestationDataFetchedEvent) - vote := &spectypes.BeaconVote{ - BlockRoot: attData.BeaconBlockRoot, - Source: attData.Source, - Target: attData.Target, + // On Gloas slots the consensus value is a GloasBeaconVote carrying the BN-supplied attestation + // index (SIP #94 §2); before Gloas it is a plain BeaconVote. Both implement spectypes.Encoder, so + // the QBFT plumbing is identical — only the value type and its checker differ. + var input spectypes.Encoder + if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(slot)) { + gloasVote := &gloas.GloasBeaconVote{ + BlockRoot: attData.BeaconBlockRoot, + Source: attData.Source, + Target: attData.Target, + AttestationDataIndex: attData.Index, + } + input = gloasVote + r.ValCheck = ssv.NewGloasVoteChecker(r.signer, slot, r.attestingValidators, gloasVote) + } else { + vote := &spectypes.BeaconVote{ + BlockRoot: attData.BeaconBlockRoot, + Source: attData.Source, + Target: attData.Target, + } + input = vote + r.ValCheck = ssv.NewVoteChecker(r.signer, slot, r.attestingValidators, vote) } r.measurements.StartConsensus() - r.ValCheck = ssv.NewVoteChecker( - r.signer, - slot, - r.attestingValidators, - vote, - ) - if err := r.decide(ctx, logger, duty.DutySlot(), vote, r.ValCheck); err != nil { + if err := r.decide(ctx, logger, duty.DutySlot(), input, r.ValCheck); err != nil { return fmt.Errorf("qbft-decide: %w", err) } @@ -1184,7 +1213,7 @@ func (r *CommitteeRunner) GetDoppelgangerHandler() DoppelgangerProvider { return r.doppelgangerHandler } -func constructAttestationData(vote *spectypes.BeaconVote, duty *spectypes.ValidatorDuty, version spec.DataVersion) *phase0.AttestationData { +func constructAttestationData(vote *spectypes.BeaconVote, duty *spectypes.ValidatorDuty, version spec.DataVersion, gloasIndex *phase0.CommitteeIndex) *phase0.AttestationData { attData := &phase0.AttestationData{ Slot: duty.Slot, Index: duty.CommitteeIndex, @@ -1192,7 +1221,11 @@ func constructAttestationData(vote *spectypes.BeaconVote, duty *spectypes.Valida Source: vote.Source, Target: vote.Target, } - if version >= spec.DataVersionElectra { + switch { + case gloasIndex != nil: + // SIP #94 §2: under Gloas the index is the decided payload-status value (0/1), not a committee index. + attData.Index = *gloasIndex + case version >= spec.DataVersionElectra: attData.Index = 0 // EIP-7549: Index should be set to 0 } return attData diff --git a/protocol/v2/ssv/runner/committee_test.go b/protocol/v2/ssv/runner/committee_test.go index c9538f871f..2d93cfc2eb 100644 --- a/protocol/v2/ssv/runner/committee_test.go +++ b/protocol/v2/ssv/runner/committee_test.go @@ -254,18 +254,27 @@ func TestConstructAttestationData(t *testing.T) { } t.Run("pre electra keeps committee index", func(t *testing.T) { - attData := constructAttestationData(vote, duty, spec.DataVersionDeneb) + attData := constructAttestationData(vote, duty, spec.DataVersionDeneb, nil) require.Equal(t, spectestingutils.TestingCommitteeIndex, attData.Index) require.Equal(t, duty.Slot, attData.Slot) require.Equal(t, vote.BlockRoot, attData.BeaconBlockRoot) }) t.Run("electra zeros committee index", func(t *testing.T) { - attData := constructAttestationData(vote, duty, spec.DataVersionElectra) + attData := constructAttestationData(vote, duty, spec.DataVersionElectra, nil) require.Zero(t, attData.Index) require.Equal(t, duty.Slot, attData.Slot) require.Equal(t, vote.BlockRoot, attData.BeaconBlockRoot) }) + + t.Run("gloas uses the decided payload-status index", func(t *testing.T) { + index := phase0.CommitteeIndex(1) + // The Gloas index overrides the Electra zero (SIP #94 §2) — it is the value that gets signed. + attData := constructAttestationData(vote, duty, spec.DataVersionFulu, &index) + require.Equal(t, index, attData.Index) + require.Equal(t, duty.Slot, attData.Slot) + require.Equal(t, vote.BlockRoot, attData.BeaconBlockRoot) + }) } func TestCommitteeRunnerStartNewDuty_StartsGuardAndResetsSubmissions(t *testing.T) { diff --git a/protocol/v2/ssv/runner/runner_validations.go b/protocol/v2/ssv/runner/runner_validations.go index 92236245c4..bbbac8b1c8 100644 --- a/protocol/v2/ssv/runner/runner_validations.go +++ b/protocol/v2/ssv/runner/runner_validations.go @@ -13,6 +13,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/ssvlabs/ssv/protocol/v2/ssv" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func (b *BaseRunner) ValidatePreConsensusMsg( @@ -137,14 +138,20 @@ func (b *BaseRunner) ValidatePostConsensusMsg(ctx context.Context, runner Runner } if runner.GetRole() == spectypes.RoleCommittee { validateMsg = func() error { - decidedValue := &spectypes.BeaconVote{} + // Use b.State.CurrentDuty.DutySlot() since CurrentDuty never changes for CommitteeRunner + // by design, hence there is no need to store slot number on decidedValue for CommitteeRunner. + expectedSlot := b.State.CurrentDuty.DutySlot() + + // Parse-check the decided value against the slot's fork: a GloasBeaconVote (120B) on Gloas, + // a BeaconVote (112B) before. The two reject on length, so a wrong-fork value fails here. + decidedValue := spectypes.Encoder(&spectypes.BeaconVote{}) + if b.NetworkConfig.IsGloas(b.NetworkConfig.EstimatedEpochAtSlot(expectedSlot)) { + decidedValue = &gloas.GloasBeaconVote{} + } if err := decidedValue.Decode(decidedValueBytes); err != nil { return fmt.Errorf("failed to parse decided value to BeaconVote: %w", err) } - // Use b.State.CurrentDuty.DutySlot() since CurrentDuty never changes for CommitteeRunner - // by design, hence there is no need to store slot number on decidedValue for CommitteeRunner. - expectedSlot := b.State.CurrentDuty.DutySlot() return b.validatePartialSigMsg(psigMsgs, expectedSlot) } } diff --git a/protocol/v2/ssv/runner/type_assertions.go b/protocol/v2/ssv/runner/type_assertions.go index 93e4239d11..de78af0d8f 100644 --- a/protocol/v2/ssv/runner/type_assertions.go +++ b/protocol/v2/ssv/runner/type_assertions.go @@ -5,6 +5,8 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func validatorDutyFromDuty(duty spectypes.Duty) (*spectypes.ValidatorDuty, error) { @@ -84,18 +86,28 @@ func (b *BaseRunner) currentDutySlot() (phase0.Slot, error) { } } -func beaconVoteFromEncoder(value spectypes.Encoder) (*spectypes.BeaconVote, error) { - if value == nil { - return nil, fmt.Errorf("decided value is nil") - } - - beaconVote, ok := value.(*spectypes.BeaconVote) - if !ok { - return nil, fmt.Errorf("decided value is not a BeaconVote: %T", value) - } - if beaconVote == nil { - return nil, fmt.Errorf("beacon vote is nil") +// decidedAttestationVote extracts the committee runner's decided consensus value as the common +// BeaconVote plus, on Gloas-and-later slots, the payload-status index it carries (SIP #94 §2). The +// concrete type — fixed by the decode prototype in ProcessConsensus — selects the fork: a +// GloasBeaconVote yields a non-nil index, a plain BeaconVote a nil one. The BeaconVote half +// (BlockRoot/Source/Target) is identical across forks, so the attestation and sync-committee paths +// consume it unchanged. +func decidedAttestationVote(value spectypes.Encoder) (*spectypes.BeaconVote, *phase0.CommitteeIndex, error) { + switch v := value.(type) { + case *gloas.GloasBeaconVote: + if v == nil { + return nil, nil, fmt.Errorf("gloas beacon vote is nil") + } + index := v.AttestationDataIndex + return &spectypes.BeaconVote{BlockRoot: v.BlockRoot, Source: v.Source, Target: v.Target}, &index, nil + case *spectypes.BeaconVote: + if v == nil { + return nil, nil, fmt.Errorf("beacon vote is nil") + } + return v, nil, nil + case nil: + return nil, nil, fmt.Errorf("decided value is nil") + default: + return nil, nil, fmt.Errorf("decided value is not a beacon vote: %T", value) } - - return beaconVote, nil } diff --git a/protocol/v2/ssv/runner/type_assertions_test.go b/protocol/v2/ssv/runner/type_assertions_test.go index 3032d89a3f..9efee4183c 100644 --- a/protocol/v2/ssv/runner/type_assertions_test.go +++ b/protocol/v2/ssv/runner/type_assertions_test.go @@ -3,9 +3,12 @@ package runner import ( "testing" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) func TestValidatorDutyFromDuty(t *testing.T) { @@ -82,20 +85,38 @@ func TestCurrentDutySlot(t *testing.T) { require.Equal(t, aggregatorCommitteeDuty.DutySlot(), slot) } -func TestBeaconVoteFromEncoder(t *testing.T) { +func TestDecidedAttestationVote(t *testing.T) { beaconVote := &spectypes.BeaconVote{} - gotBeaconVote, err := beaconVoteFromEncoder(beaconVote) + gotVote, gotIndex, err := decidedAttestationVote(beaconVote) + require.NoError(t, err) + require.Same(t, beaconVote, gotVote) + require.Nil(t, gotIndex) // no attestation index before Gloas + + // Gloas: the BeaconVote half is extracted and the carried attestation index is returned (non-nil). + gloasVote := &gloas.GloasBeaconVote{ + BlockRoot: phase0.Root{0x01}, + Source: &phase0.Checkpoint{}, + Target: &phase0.Checkpoint{Epoch: 1}, + AttestationDataIndex: 1, + } + gotVote, gotIndex, err = decidedAttestationVote(gloasVote) require.NoError(t, err) - require.Same(t, beaconVote, gotBeaconVote) + require.Equal(t, gloasVote.BlockRoot, gotVote.BlockRoot) + require.NotNil(t, gotIndex) + require.Equal(t, phase0.CommitteeIndex(1), *gotIndex) - _, err = beaconVoteFromEncoder(nil) + _, _, err = decidedAttestationVote(nil) require.ErrorContains(t, err, "decided value is nil") var nilBeaconVote *spectypes.BeaconVote - _, err = beaconVoteFromEncoder(nilBeaconVote) + _, _, err = decidedAttestationVote(nilBeaconVote) require.ErrorContains(t, err, "beacon vote is nil") - _, err = beaconVoteFromEncoder(&spectypes.ProposerConsensusData{}) - require.ErrorContains(t, err, "decided value is not a BeaconVote") + var nilGloasVote *gloas.GloasBeaconVote + _, _, err = decidedAttestationVote(nilGloasVote) + require.ErrorContains(t, err, "gloas beacon vote is nil") + + _, _, err = decidedAttestationVote(&spectypes.ProposerConsensusData{}) + require.ErrorContains(t, err, "decided value is not a beacon vote") } From d58fd4bdcabb4516728b0bf76afd16224175d2bb Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 14:43:22 +0300 Subject: [PATCH 031/150] =?UTF-8?q?gloas:=20recognize=20=C2=A72=20GloasBea?= =?UTF-8?q?conVote=20in=20observer=20+=20duty=20tracer=20(SIP=20#94=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the committee runner's fork-aware decode in the two observability paths so they recognize Gloas attestations. Neither is on the signing path. - Explorer (committee_observer.go): decode GloasBeaconVote and compute the single attester root for the decided payload-status index, instead of the 0..63 committee-index loop (pre-Gloas, where each validator's committee isn't known). - Duty tracer (collector.go): decode it and use the decided index for the attester root. Sync-committee roots read only BlockRoot, unchanged. Plus two cleanups uncovered along the way: - ProcessConsensus fetches the running committee duty once (it fixes both the decode prototype's fork and the post-decide committee slot) instead of twice. - getSyncCommitteeRoot takes the BlockRoot directly, so computeRoleRoots decodes the vote a single time. --- exporter/dutytracer/collector.go | 47 +++++++++++++------ exporter/dutytracer/collector_test.go | 3 +- protocol/v2/ssv/runner/committee.go | 18 +++---- .../v2/ssv/validator/committee_observer.go | 35 ++++++++++++-- 4 files changed, 74 insertions(+), 29 deletions(-) diff --git a/exporter/dutytracer/collector.go b/exporter/dutytracer/collector.go index 586fd651c0..539bf8bf24 100644 --- a/exporter/dutytracer/collector.go +++ b/exporter/dutytracer/collector.go @@ -30,6 +30,7 @@ import ( "github.com/ssvlabs/ssv/operator/slotticker" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" registrystorage "github.com/ssvlabs/ssv/registry/storage" "github.com/ssvlabs/ssv/utils/hashmap" ) @@ -501,13 +502,27 @@ func (c *Collector) processPartialSigCommittee( } } -func (c *Collector) getSyncCommitteeRoot(ctx context.Context, slot phase0.Slot, in []byte) (phase0.Root, error) { - var beaconVote = new(spectypes.BeaconVote) - if err := beaconVote.Decode(in); err != nil { - return phase0.Root{}, fmt.Errorf("decode beacon vote: %w", err) +// decodeCommitteeVote decodes committee FullData as the common BeaconVote plus, on Gloas slots, the +// carried attestation index (nil before Gloas) — the duty tracer's mirror of the runner's fork-aware +// decode, so the signing roots it derives match what operators actually signed. +func (c *Collector) decodeCommitteeVote(slot phase0.Slot, in []byte) (*spectypes.BeaconVote, *phase0.CommitteeIndex, error) { + if c.beacon.IsGloas(c.beacon.EstimatedEpochAtSlot(slot)) { + gv := &gloas.GloasBeaconVote{} + if err := gv.Decode(in); err != nil { + return nil, nil, fmt.Errorf("decode gloas beacon vote: %w", err) + } + index := gv.AttestationDataIndex + return &spectypes.BeaconVote{BlockRoot: gv.BlockRoot, Source: gv.Source, Target: gv.Target}, &index, nil + } + bv := new(spectypes.BeaconVote) + if err := bv.Decode(in); err != nil { + return nil, nil, fmt.Errorf("decode beacon vote: %w", err) } + return bv, nil, nil +} - key := scRootKey{slot: slot, blockRoot: beaconVote.BlockRoot} +func (c *Collector) getSyncCommitteeRoot(ctx context.Context, slot phase0.Slot, blockRoot phase0.Root) (phase0.Root, error) { + key := scRootKey{slot: slot, blockRoot: blockRoot} // lookup in cache first cacheItem := c.syncCommitteeRootsCache.Get(key) @@ -516,14 +531,14 @@ func (c *Collector) getSyncCommitteeRoot(ctx context.Context, slot phase0.Slot, } // Use singleflight to ensure only one goroutine computes the root for a given key - sfKey := fmt.Sprintf("%d-%s", slot, beaconVote.BlockRoot.String()) + sfKey := fmt.Sprintf("%d-%s", slot, blockRoot.String()) val, err, _ := c.syncCommitteeRootsSf.Do(sfKey, func() (any, error) { // Check cache again in case another goroutine has populated it while we were waiting if cacheItem := c.syncCommitteeRootsCache.Get(key); cacheItem != nil { return cacheItem.Value(), nil } - c.logger.Info("fetching sync committee root", fields.Slot(slot), fields.Root(beaconVote.BlockRoot)) + c.logger.Info("fetching sync committee root", fields.Slot(slot), fields.Root(blockRoot)) epoch := c.beacon.EstimatedEpochAtSlot(slot) @@ -533,8 +548,8 @@ func (c *Collector) getSyncCommitteeRoot(ctx context.Context, slot phase0.Slot, } // Beacon root - blockRoot := spectypes.SSZBytes(beaconVote.BlockRoot[:]) - signingRoot, err := spectypes.ComputeETHSigningRoot(blockRoot, domain) + blockRootSSZ := spectypes.SSZBytes(blockRoot[:]) + signingRoot, err := spectypes.ComputeETHSigningRoot(blockRootSSZ, domain) if err != nil { return phase0.Root{}, fmt.Errorf("compute sync committee root: %w", err) } @@ -658,23 +673,27 @@ func (c *Collector) computeAggregatorCommitteePostConsensusRoles( // computeRoleRoots derives both sync-committee and attestation signing roots // from a proposal FullData (BeaconVote) for the given slot. func (c *Collector) computeRoleRoots(ctx context.Context, slot phase0.Slot, in []byte) (phase0.Root, phase0.Root, error) { - syncRoot, err := c.getSyncCommitteeRoot(ctx, slot, in) + vote, gloasIndex, err := c.decodeCommitteeVote(slot, in) if err != nil { return phase0.Root{}, phase0.Root{}, err } - var vote spectypes.BeaconVote - if err := vote.Decode(in); err != nil { - return phase0.Root{}, phase0.Root{}, fmt.Errorf("decode beacon vote: %w", err) + syncRoot, err := c.getSyncCommitteeRoot(ctx, slot, vote.BlockRoot) + if err != nil { + return phase0.Root{}, phase0.Root{}, err } epoch := c.beacon.EstimatedEpochAtSlot(slot) domain, err := c.client.DomainData(ctx, epoch, spectypes.DomainAttester) if err != nil { return phase0.Root{}, phase0.Root{}, fmt.Errorf("get attester domain data: %w", err) } + index := phase0.CommitteeIndex(0) // Electra semantics (EIP-7549) + if gloasIndex != nil { + index = *gloasIndex // SIP #94 §2: Gloas attestations are signed with the payload-status index + } attData := &phase0.AttestationData{ Slot: slot, - Index: 0, // Electra semantics (EIP-7549) + Index: index, BeaconBlockRoot: vote.BlockRoot, Source: vote.Source, Target: vote.Target, diff --git a/exporter/dutytracer/collector_test.go b/exporter/dutytracer/collector_test.go index f607e3a65d..1e8c06bbc6 100644 --- a/exporter/dutytracer/collector_test.go +++ b/exporter/dutytracer/collector_test.go @@ -926,8 +926,7 @@ func TestDutyTracer_SyncCommitteeRoots(t *testing.T) { bnVote := &spectypes.BeaconVote{BlockRoot: [32]byte{1, 2, 3}} - data, _ := bnVote.Encode() - root, err := collector.getSyncCommitteeRoot(t.Context(), 1, data) + root, err := collector.getSyncCommitteeRoot(t.Context(), 1, bnVote.BlockRoot) require.NoError(t, err) wantRoot := [32]byte{3, 73, 222, 196, 134, 206, 159, 128, diff --git a/protocol/v2/ssv/runner/committee.go b/protocol/v2/ssv/runner/committee.go index 0e97dc30be..f2e937a480 100644 --- a/protocol/v2/ssv/runner/committee.go +++ b/protocol/v2/ssv/runner/committee.go @@ -220,16 +220,19 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg // Reuse the existing span instead of generating new one to keep tracing-data lightweight. span := trace.SpanFromContext(ctx) - span.AddEvent("processing QBFT consensus msg") + // Fetch the running duty once: it fixes both the decode prototype's fork and, post-decide, the + // committee slot. A consensus message with no running duty can't decide, so the fetch error only + // matters once we know we decided. + committeeDuty, dutyErr := r.currentCommitteeDuty() // The decided value is a GloasBeaconVote (which carries the attestation index) on Gloas slots, a - // plain BeaconVote before. Pick the decode prototype from the running duty's fork; a message with no - // running duty cannot decide, so the default is harmless there. + // plain BeaconVote before; decode into the matching prototype. decidedPrototype := spectypes.Encoder(&spectypes.BeaconVote{}) - if committeeDuty, dutyErr := r.currentCommitteeDuty(); dutyErr == nil && r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(committeeDuty.DutySlot())) { + if dutyErr == nil && r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(committeeDuty.DutySlot())) { decidedPrototype = &gloas.GloasBeaconVote{} } + span.AddEvent("processing QBFT consensus msg") decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, msg, decidedPrototype) if err != nil { return fmt.Errorf("failed processing consensus message: %w", err) @@ -239,14 +242,13 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg if !decided { return nil } + if dutyErr != nil { + return fmt.Errorf("current committee duty: %w", dutyErr) + } r.measurements.EndConsensus() recordConsensusDuration(ctx, r.measurements.ConsensusTime(), spectypes.RoleCommittee) - committeeDuty, err := r.currentCommitteeDuty() - if err != nil { - return fmt.Errorf("current committee duty: %w", err) - } committeeDutySlot := committeeDuty.DutySlot() postConsensusMsg := &spectypes.PartialSignatureMessages{ Type: spectypes.PostConsensusPartialSig, diff --git a/protocol/v2/ssv/validator/committee_observer.go b/protocol/v2/ssv/validator/committee_observer.go index c542072746..504bf503fa 100644 --- a/protocol/v2/ssv/validator/committee_observer.go +++ b/protocol/v2/ssv/validator/committee_observer.go @@ -27,6 +27,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" registrystorage "github.com/ssvlabs/ssv/registry/storage" ) @@ -427,8 +428,20 @@ func (ncv *CommitteeObserver) SaveRoots(ctx context.Context, msg *queue.SSVMessa switch msg.MsgID.GetRoleType() { case spectypes.RoleCommittee: + // On Gloas slots the decided value is a GloasBeaconVote (120B) carrying the attestation index; + // before Gloas it is a plain BeaconVote (112B). Decode into the matching type. beaconVote := &spectypes.BeaconVote{} - if err := beaconVote.Decode(msg.SignedSSVMessage.FullData); err != nil { + var gloasIndex *phase0.CommitteeIndex + if ncv.beaconConfig.IsGloas(epoch) { + gv := &gloas.GloasBeaconVote{} + if err := gv.Decode(msg.SignedSSVMessage.FullData); err != nil { + ncv.logger.Debug("❗ failed to decode gloas beacon vote from proposal", zap.Error(err)) + return err + } + beaconVote = &spectypes.BeaconVote{BlockRoot: gv.BlockRoot, Source: gv.Source, Target: gv.Target} + index := gv.AttestationDataIndex + gloasIndex = &index + } else if err := beaconVote.Decode(msg.SignedSSVMessage.FullData); err != nil { ncv.logger.Debug("❗ failed to decode beacon vote from proposal", zap.Error(err)) return err } @@ -439,7 +452,7 @@ func (ncv *CommitteeObserver) SaveRoots(ctx context.Context, msg *queue.SSVMessa return nil } - if err := ncv.saveAttesterRoots(ctx, epoch, beaconVote, qbftMsg); err != nil { + if err := ncv.saveAttesterRoots(ctx, epoch, beaconVote, gloasIndex, qbftMsg); err != nil { return err } if err := ncv.saveSyncCommRoots(ctx, epoch, beaconVote); err != nil { @@ -483,20 +496,32 @@ func (ncv *CommitteeObserver) SaveRoots(ctx context.Context, msg *queue.SSVMessa } } -func (ncv *CommitteeObserver) saveAttesterRoots(ctx context.Context, epoch phase0.Epoch, beaconVote *spectypes.BeaconVote, qbftMsg *specqbft.Message) error { +func (ncv *CommitteeObserver) saveAttesterRoots(ctx context.Context, epoch phase0.Epoch, beaconVote *spectypes.BeaconVote, gloasIndex *phase0.CommitteeIndex, qbftMsg *specqbft.Message) error { attesterDomain, err := ncv.domainCache.Get(ctx, epoch, spectypes.DomainAttester) if err != nil { return err } - for committeeIndex := phase0.CommitteeIndex(0); committeeIndex < 64; committeeIndex++ { + saveRoot := func(committeeIndex phase0.CommitteeIndex) error { attestationData := constructAttestationData(beaconVote, phase0.Slot(qbftMsg.Height), committeeIndex) attesterRoot, err := spectypes.ComputeETHSigningRoot(attestationData, attesterDomain) if err != nil { return err } - ncv.attesterRoots.Set(attesterRoot, struct{}{}, ttlcache.DefaultTTL) + return nil + } + + // On Gloas the index is the single decided payload-status value (0/1), shared by every validator in + // the slot, so there is exactly one attester root. Before Gloas the observer doesn't know each + // validator's committee, so it precomputes the root for every committee index 0..63. + if gloasIndex != nil { + return saveRoot(*gloasIndex) + } + for committeeIndex := phase0.CommitteeIndex(0); committeeIndex < 64; committeeIndex++ { + if err := saveRoot(committeeIndex); err != nil { + return err + } } return nil From 4a8261a2b2a70431ce6b834eb77233093e5cb996 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 14:57:35 +0300 Subject: [PATCH 032/150] =?UTF-8?q?gloas:=20test=20=C2=A72=20fork-aware=20?= =?UTF-8?q?decode=20in=20observer=20+=20duty=20tracer=20(SIP=20#94=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestDutyTracer_DecodeCommitteeVote: a BeaconVote decodes with no attestation index before Gloas; a GloasBeaconVote yields the common vote (BlockRoot + Source/Target) plus the carried payload-status index on Gloas. - TestCommitteeObserver_saveAttesterRoots_GloasSingleRoot: the observer precomputes a single attester root for the decided index on Gloas (and it is that index's root), versus one per committee index 0..63 before. --- exporter/dutytracer/collector_test.go | 27 +++++++++++++ .../ssv/validator/committee_observer_test.go | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/exporter/dutytracer/collector_test.go b/exporter/dutytracer/collector_test.go index 1e8c06bbc6..b850f6cf0e 100644 --- a/exporter/dutytracer/collector_test.go +++ b/exporter/dutytracer/collector_test.go @@ -25,6 +25,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" "github.com/ssvlabs/ssv/registry/storage" registrystoragemocks "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" @@ -935,6 +936,32 @@ func TestDutyTracer_SyncCommitteeRoots(t *testing.T) { assert.Equal(t, phase0.Root(wantRoot), root) } +func TestDutyTracer_DecodeCommitteeVote(t *testing.T) { + // Pre-Gloas: a BeaconVote decodes to the common vote with no attestation index. + preGloas := New(zap.NewNop(), nil, mockclient{}, nil, networkconfig.TestNetwork.Beacon, nil, nil) + bv := &spectypes.BeaconVote{BlockRoot: phase0.Root{1, 2, 3}, Source: &phase0.Checkpoint{}, Target: &phase0.Checkpoint{Epoch: 1}} + bvBytes, err := bv.Encode() + require.NoError(t, err) + + gotVote, gotIndex, err := preGloas.decodeCommitteeVote(1, bvBytes) + require.NoError(t, err) + require.Equal(t, bv.BlockRoot, gotVote.BlockRoot) + require.Nil(t, gotIndex) + + // Gloas (fork at epoch 0): a GloasBeaconVote yields the common vote plus the payload-status index. + gloasTracer := New(zap.NewNop(), nil, mockclient{}, nil, networkconfig.TestNetworkWithGloas(0).Beacon, nil, nil) + gv := &gloas.GloasBeaconVote{BlockRoot: phase0.Root{4, 5, 6}, Source: &phase0.Checkpoint{}, Target: &phase0.Checkpoint{Epoch: 1}, AttestationDataIndex: 1} + gvBytes, err := gv.Encode() + require.NoError(t, err) + + gotVote, gotIndex, err = gloasTracer.decodeCommitteeVote(1, gvBytes) + require.NoError(t, err) + require.Equal(t, gv.BlockRoot, gotVote.BlockRoot) + require.Equal(t, phase0.Epoch(1), gotVote.Target.Epoch) // Source/Target carried over, not just BlockRoot + require.NotNil(t, gotIndex) + require.Equal(t, phase0.CommitteeIndex(1), *gotIndex) +} + type mockclient struct{} func (m mockclient) DomainData(ctx context.Context, epoch phase0.Epoch, domain phase0.DomainType) (phase0.Domain, error) { diff --git a/protocol/v2/ssv/validator/committee_observer_test.go b/protocol/v2/ssv/validator/committee_observer_test.go index bff005d6b7..4d8a1b10c0 100644 --- a/protocol/v2/ssv/validator/committee_observer_test.go +++ b/protocol/v2/ssv/validator/committee_observer_test.go @@ -1,11 +1,15 @@ package validator import ( + "context" "encoding/hex" "fmt" "testing" + "time" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/jellydator/ttlcache/v3" + specqbft "github.com/ssvlabs/ssv-spec/qbft" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -72,3 +76,37 @@ func TestCommitteeObserver_VerifySig_MissingValidatorLogsContext(t *testing.T) { require.EqualValues(t, 1, fields["post_consensus_container_slots"]) require.Equal(t, false, fields["own_validator"]) } + +// On Gloas the committee shares one decided payload-status index, so the observer precomputes a single +// attester root; before Gloas, not knowing each validator's committee, it precomputes all 64. +func TestCommitteeObserver_saveAttesterRoots_GloasSingleRoot(t *testing.T) { + const epoch = phase0.Epoch(3) + + domainCache := &DomainCache{cache: ttlcache.New(ttlcache.WithTTL[domainCacheKey, phase0.Domain](time.Hour))} + domainCache.cache.Set(domainCacheKey{Epoch: epoch, DomainType: spectypes.DomainAttester}, phase0.Domain{}, ttlcache.DefaultTTL) + + newObserver := func() *CommitteeObserver { + return &CommitteeObserver{ + domainCache: domainCache, + attesterRoots: ttlcache.New(ttlcache.WithTTL[phase0.Root, struct{}](time.Hour)), + } + } + + beaconVote := &spectypes.BeaconVote{BlockRoot: phase0.Root{1}, Source: &phase0.Checkpoint{}, Target: &phase0.Checkpoint{Epoch: 1}} + qbftMsg := &specqbft.Message{Height: 100} + + gloasObserver := newObserver() + index := phase0.CommitteeIndex(1) + require.NoError(t, gloasObserver.saveAttesterRoots(context.Background(), epoch, beaconVote, &index, qbftMsg)) + require.Equal(t, 1, gloasObserver.attesterRoots.Len()) + + // the single root is the one for the decided index, not some other committee index + wantData := constructAttestationData(beaconVote, phase0.Slot(qbftMsg.Height), index) + wantRoot, err := spectypes.ComputeETHSigningRoot(wantData, phase0.Domain{}) + require.NoError(t, err) + require.True(t, gloasObserver.attesterRoots.Has(wantRoot)) + + preGloasObserver := newObserver() + require.NoError(t, preGloasObserver.saveAttesterRoots(context.Background(), epoch, beaconVote, nil, qbftMsg)) + require.Equal(t, 64, preGloasObserver.attesterRoots.Len()) +} From 974c6c8566d03f627df5756aec2fcad64dbce4c9 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 15:34:27 +0300 Subject: [PATCH 033/150] =?UTF-8?q?gloas:=20retime=20duty=20deadlines=20to?= =?UTF-8?q?=20quarters=20at=20the=20fork=20(SIP=20#94=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ePBS moves duty deadlines from thirds of the slot to quarters. Every deadline is N × IntervalDuration with N preserved across the fork (attestation/sync 1×, aggregate/contribution 2×, payload attestation 3×), so the whole change is one fork-gate: (*Beacon).IntervalDuration() becomes IntervalDuration(slot), returning SlotDuration/3 before Gloas and SlotDuration/4 from Gloas on. Every caller passes its slot — the scheduler attestation timer and head-event acceleration check, the attester/proposer indices-change deadline, the aggregator-committee runner, and the goclient aggregator and sync-contribution waits — so each lands on the right quarter automatically. Pre-Gloas behavior is byte-identical (TestNetwork has no Gloas fork). The misleadingly-named waitOneThird*/waitTwoThirds* helpers are renamed waitOneInterval*/waitTwoIntervals* with fork-aware comments and logs. PTC's 75% (PayloadAttestationCutoff) was already correct and is untouched. Adds TestBeacon_IntervalDuration. --- beacon/goclient/aggregator.go | 40 ++++++++++--------- beacon/goclient/aggregator_test.go | 2 +- .../goclient/sync_committee_contribution.go | 22 +--------- networkconfig/beacon.go | 13 ++++-- networkconfig/beacon_gloas_test.go | 13 ++++++ operator/duties/attester.go | 2 +- operator/duties/attester_test.go | 2 +- operator/duties/proposer.go | 2 +- operator/duties/proposer_test.go | 2 +- operator/duties/scheduler.go | 20 +++++----- operator/duties/sync_committee.go | 2 +- operator/duties/sync_committee_test.go | 2 +- .../v2/ssv/runner/aggregator_committee.go | 8 ++-- 13 files changed, 66 insertions(+), 64 deletions(-) diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index fb88fe0b26..178ec4d134 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -52,7 +52,7 @@ func (gc *GoClient) SubmitAggregateSelectionProof( // As specified in spec, an aggregator should wait until two thirds of the way through slot // to broadcast the best aggregate to the global aggregate channel. // https://github.com/ethereum/consensus-specs/blob/v0.9.3/specs/validator/0_beacon-chain-validator.md#broadcast-aggregate - if err := gc.waitTwoThirdsIntoSlot(ctx, slot); err != nil { + if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { return nil, 0, fmt.Errorf("wait for 2/3 of slot: %w", err) } @@ -82,6 +82,26 @@ func (gc *GoClient) SubmitSignedAggregateSelectionProof( return nil } +// waitIntoSlot waits until the given number of intervals into the slot has transpired +// (intervals * IntervalDuration after the start of the slot): intervals=1 is one interval in +// (attestation/contribution deadline), intervals=2 is two intervals in (aggregate broadcast +// deadline). IntervalDuration is 1/3 of the slot before Gloas, 1/4 from Gloas on (SIP #94 §1). +func (gc *GoClient) waitIntoSlot(ctx context.Context, slot phase0.Slot, intervals int) error { + config := gc.getBeaconConfig() + finalTime := config.SlotStartTime(slot).Add(time.Duration(intervals) * config.IntervalDuration(slot)) + wait := time.Until(finalTime) + if wait <= 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + return nil + } +} + // computeAttestationDataRoot re-derives the attestation data root for the given slot/committee // from this node's own view, used as a fallback when the cluster-attested root is unknown. func (gc *GoClient) computeAttestationDataRoot( @@ -266,21 +286,3 @@ func versionedToAggregateAndProof( return nil, DataVersionNil, fmt.Errorf("unknown data version: %d", va.Version) } } - -// waitTwoThirdsIntoSlot waits until two-third of the slot has transpired (SECONDS_PER_SLOT * 2 / 3 seconds after the start of slot) -func (gc *GoClient) waitTwoThirdsIntoSlot(ctx context.Context, slot phase0.Slot) error { - config := gc.getBeaconConfig() - oneInterval := config.IntervalDuration() - finalTime := config.SlotStartTime(slot).Add(2 * oneInterval) - wait := time.Until(finalTime) - if wait <= 0 { - return nil - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(wait): - return nil - } -} diff --git a/beacon/goclient/aggregator_test.go b/beacon/goclient/aggregator_test.go index c84620c4b8..f9b0f0ca1b 100644 --- a/beacon/goclient/aggregator_test.go +++ b/beacon/goclient/aggregator_test.go @@ -283,7 +283,7 @@ func TestSubmitAggregateSelectionProof_RespectsContextCancellationWhileWaiting(t errCh <- err }() - time.Sleep(cfg.IntervalDuration()) + time.Sleep(cfg.IntervalDuration(0)) cancel() err := <-errCh diff --git a/beacon/goclient/sync_committee_contribution.go b/beacon/goclient/sync_committee_contribution.go index f2deabbab4..7da45ba782 100644 --- a/beacon/goclient/sync_committee_contribution.go +++ b/beacon/goclient/sync_committee_contribution.go @@ -51,7 +51,7 @@ func (gc *GoClient) GetSyncCommitteeContribution( return nil, DataVersionNil, fmt.Errorf("mismatching number of selection proofs and subnet IDs") } - if err := gc.waitOneThirdIntoSlot(ctx, slot); err != nil { + if err := gc.waitIntoSlot(ctx, slot, 1); err != nil { return nil, DataVersionNil, fmt.Errorf("wait for 1/3 of slot: %w", err) } @@ -75,7 +75,7 @@ func (gc *GoClient) GetSyncCommitteeContribution( blockRoot := beaconBlockRootResp.Data - if err := gc.waitTwoThirdsIntoSlot(ctx, slot); err != nil { + if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { return nil, DataVersionNil, fmt.Errorf("wait for 2/3 of slot: %w", err) } @@ -129,21 +129,3 @@ func (gc *GoClient) SubmitSignedContributionAndProof( return nil } - -// waitOneThirdIntoSlot waits until one-third of the slot has transpired (SECONDS_PER_SLOT / 3 seconds after slot start time) -func (gc *GoClient) waitOneThirdIntoSlot(ctx context.Context, slot phase0.Slot) error { - config := gc.getBeaconConfig() - delay := config.IntervalDuration() - finalTime := config.SlotStartTime(slot).Add(delay) - wait := time.Until(finalTime) - if wait <= 0 { - return nil - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(wait): - return nil - } -} diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index 0fffe2540a..8e60e90d9c 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -139,10 +139,15 @@ func (b *Beacon) TimeAtSlot(slot phase0.Slot) time.Time { return b.GenesisTime.Add(d) } -func (b *Beacon) IntervalDuration() time.Duration { - // intervalsPerSlot is always 3 as per https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#constant - const intervalsPerSlot = 3 - return b.SlotDuration / intervalsPerSlot +// IntervalDuration is the slot fraction that duty deadlines are multiples of: 1/3 of the slot before +// Gloas, 1/4 from Gloas on. ePBS retimes the deadlines to quarters — attestation/sync 1× (25%), +// aggregate/contribution 2× (50%), payload attestation 3× (75%); SIP #94 §1. +func (b *Beacon) IntervalDuration(slot phase0.Slot) time.Duration { + intervalsPerSlot := 3 + if b.IsGloas(b.EstimatedEpochAtSlot(slot)) { + intervalsPerSlot = 4 + } + return b.SlotDuration / time.Duration(intervalsPerSlot) } func (b *Beacon) EpochDuration() time.Duration { diff --git a/networkconfig/beacon_gloas_test.go b/networkconfig/beacon_gloas_test.go index 0354c8f465..dd97e684af 100644 --- a/networkconfig/beacon_gloas_test.go +++ b/networkconfig/beacon_gloas_test.go @@ -53,3 +53,16 @@ func TestNetwork_InGloasPriorWindow(t *testing.T) { // No Gloas fork scheduled → never in the window. require.False(t, TestNetwork.InGloasPriorWindow(slotInEpoch(gloasEpoch-1))) } + +// IntervalDuration is a third of the slot before Gloas, a quarter from the fork on (SIP #94 §1). +func TestBeacon_IntervalDuration(t *testing.T) { + // No Gloas fork → always thirds. + require.Equal(t, TestNetwork.SlotDuration/3, TestNetwork.IntervalDuration(0)) + require.Equal(t, TestNetwork.SlotDuration/3, TestNetwork.IntervalDuration(1_000_000)) + + // Gloas at epoch 100: thirds before the fork, quarters from it on. + const gloasEpoch = 100 + netCfg := TestNetworkWithGloas(gloasEpoch) + require.Equal(t, netCfg.SlotDuration/3, netCfg.IntervalDuration(netCfg.FirstSlotAtEpoch(gloasEpoch)-1)) + require.Equal(t, netCfg.SlotDuration/4, netCfg.IntervalDuration(netCfg.FirstSlotAtEpoch(gloasEpoch))) +} diff --git a/operator/duties/attester.go b/operator/duties/attester.go index 3043d9420c..88079468b4 100644 --- a/operator/duties/attester.go +++ b/operator/duties/attester.go @@ -136,7 +136,7 @@ func (h *AttesterHandler) HandleDuties(ctx context.Context) { // if we are still early into the slot (1 slot-interval is just a guesstimate), otherwise we might // be delaying the next tick (the duties that need to be executed on the next slot). - indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration()) + indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration(currentSlot)) select { case <-h.indicesChangeCh: logger.Info("🔁 indices change received") diff --git a/operator/duties/attester_test.go b/operator/duties/attester_test.go index fce14e585e..3f42f17901 100644 --- a/operator/duties/attester_test.go +++ b/operator/duties/attester_test.go @@ -1127,7 +1127,7 @@ func TestScheduler_Attester_Indices_Changed_Too_Late_In_Slot(t *testing.T) { }, }) go func() { - time.Sleep(scheduler.netCfg.IntervalDuration() + 1*time.Millisecond) + time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} }() diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index a5ce75421e..63fa7f23b7 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -126,7 +126,7 @@ func (h *ProposerHandler) HandleDuties(ctx context.Context) { // if we are still early into the slot (1 slot-interval is just a guesstimate), otherwise we might // be delaying the next tick (the duties that need to be executed on the next slot). - indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration()) + indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration(currentSlot)) select { case <-h.indicesChangeCh: logger.Info("🔁 indices change received") diff --git a/operator/duties/proposer_test.go b/operator/duties/proposer_test.go index c9447efa23..bde43cf991 100644 --- a/operator/duties/proposer_test.go +++ b/operator/duties/proposer_test.go @@ -1067,7 +1067,7 @@ func TestScheduler_Proposer_Indices_Changed_Too_Late_In_Slot(t *testing.T) { }, }) go func() { - time.Sleep(scheduler.netCfg.IntervalDuration() + 1*time.Millisecond) + time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} }() diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index 202a667eb9..c6ebe5a6e2 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -359,7 +359,7 @@ func (s *Scheduler) SlotTicker(ctx context.Context) { case <-s.ticker.Next(): slot := s.ticker.Slot() - delay := s.netCfg.IntervalDuration() + delay := s.netCfg.IntervalDuration(slot) finalTime := s.netCfg.SlotStartTime(slot).Add(delay) waitDuration := time.Until(finalTime) if waitDuration > 0 { @@ -441,10 +441,10 @@ func (s *Scheduler) HandleHeadEvent() func(ctx context.Context, event *eth2apiv1 s.currentDutyDependentRoot = event.CurrentDutyDependentRoot currentTime := time.Now() - delay := s.netCfg.IntervalDuration() + delay := s.netCfg.IntervalDuration(event.Slot) slotStartTimeWithDelay := s.netCfg.SlotStartTime(event.Slot).Add(delay) if currentTime.Before(slotStartTimeWithDelay) { - logger.Debug("🏁 Head event: Block arrived before 1/3 slot", zap.Duration("time_saved", slotStartTimeWithDelay.Sub(currentTime))) + logger.Debug("🏁 Head event: Block arrived before the attestation deadline", zap.Duration("time_saved", slotStartTimeWithDelay.Sub(currentTime))) // We give the block some time to propagate around the rest of the // nodes before kicking off duties for the block's slot. @@ -570,7 +570,7 @@ func (s *Scheduler) ExecuteCommitteeDuties(ctx context.Context, duties committee defer cancel() if role == spectypes.RoleCommittee { - s.waitOneThirdIntoSlotOrValidBlock(slot) + s.waitOneIntervalIntoSlotOrValidBlock(slot) } s.dutyExecutor.ExecuteCommitteeDuty(dutyCtx, logger, committee.id, duty) }() @@ -630,15 +630,15 @@ func (s *Scheduler) advanceHeadSlot(slot phase0.Slot) { s.waitCond.L.Unlock() } -// waitOneThirdIntoSlotOrValidBlock waits until one-third of the slot has passed (SECONDS_PER_SLOT / 3 seconds after -// slot start time), or for a head block event that might come in even sooner than one-third of the slot passes. -func (s *Scheduler) waitOneThirdIntoSlotOrValidBlock(slot phase0.Slot) { - s.logger.Debug("waiting 1/3 into slot (maybe)") - defer s.logger.Debug("waiting 1/3 into slot (done)") +// waitOneIntervalIntoSlotOrValidBlock waits until the attestation deadline (one interval into the slot — 1/3 +// before Gloas, 1/4 from Gloas on), or for a head block event that might come in even sooner. +func (s *Scheduler) waitOneIntervalIntoSlotOrValidBlock(slot phase0.Slot) { + s.logger.Debug("waiting one interval into slot (maybe)") + defer s.logger.Debug("waiting one interval into slot (done)") s.waitCond.L.Lock() for s.headSlot < slot { - s.logger.Debug("waiting 1/3 into slot", + s.logger.Debug("waiting one interval into slot", zap.Uint64("current_head_slot", uint64(s.headSlot)), zap.Uint64("slot", uint64(slot)), ) diff --git a/operator/duties/sync_committee.go b/operator/duties/sync_committee.go index 24f730c8bc..9d68c30f66 100644 --- a/operator/duties/sync_committee.go +++ b/operator/duties/sync_committee.go @@ -146,7 +146,7 @@ func (h *SyncCommitteeHandler) HandleDuties(ctx context.Context) { // if we are still early into the slot (1 slot-interval is just a guesstimate), otherwise we might // be delaying the next tick (the duties that need to be executed on the next slot). - indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration()) + indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration(currentSlot)) select { case <-h.indicesChangeCh: logger.Info("🔁 indices change received") diff --git a/operator/duties/sync_committee_test.go b/operator/duties/sync_committee_test.go index bb829c62b8..0a0d456013 100644 --- a/operator/duties/sync_committee_test.go +++ b/operator/duties/sync_committee_test.go @@ -705,7 +705,7 @@ func TestScheduler_SyncCommittee_Indices_Changed_Too_Late_In_Slot(t *testing.T) }, }) go func() { - time.Sleep(scheduler.netCfg.IntervalDuration() + 1*time.Millisecond) + time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} }() diff --git a/protocol/v2/ssv/runner/aggregator_committee.go b/protocol/v2/ssv/runner/aggregator_committee.go index 43faab3f4c..6bfe092245 100644 --- a/protocol/v2/ssv/runner/aggregator_committee.go +++ b/protocol/v2/ssv/runner/aggregator_committee.go @@ -231,9 +231,9 @@ func (r *AggregatorCommitteeRunner) findValidatorDuty( return nil } -// waitTwoThirdsIntoSlot waits until two-thirds of the slot has passed. -func (r *AggregatorCommitteeRunner) waitTwoThirdsIntoSlot(ctx context.Context, slot phase0.Slot) error { - finalTime := r.NetworkConfig.SlotStartTime(slot).Add(2 * r.NetworkConfig.IntervalDuration()) +// waitTwoIntervalsIntoSlot waits until the aggregation deadline — 2/3 of the slot before Gloas, 1/2 from Gloas on. +func (r *AggregatorCommitteeRunner) waitTwoIntervalsIntoSlot(ctx context.Context, slot phase0.Slot) error { + finalTime := r.NetworkConfig.SlotStartTime(slot).Add(2 * r.NetworkConfig.IntervalDuration(slot)) wait := time.Until(finalTime) if wait <= 0 { return nil @@ -595,7 +595,7 @@ func (r *AggregatorCommitteeRunner) ProcessPreConsensus( if len(aggregatorSelections) > 0 { // Wait once per duty before fetching aggregate attestations (spec: 2/3 into slot). - if err := r.waitTwoThirdsIntoSlot(ctx, duty.DutySlot()); err != nil { + if err := r.waitTwoIntervalsIntoSlot(ctx, duty.DutySlot()); err != nil { // Only reachable on shutdown (ctx canceled) within this short wait — markDutyFailed // would drop a context.Canceled reason anyway, so there is nothing to record here. return err From b0584fe95ed4a78b44f2b933fb933eedf02c0b85 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 18:34:58 +0300 Subject: [PATCH 034/150] =?UTF-8?q?gloas:=20add=20node-side=20Gloas=20bloc?= =?UTF-8?q?k=20+=20bid=20SSZ=20types=20(SIP=20#94=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §4 proposer track needs Gloas BeaconBlock types, and go-eth2-client has none (upstream Gloas is only in unmerged draft PRs). Hand-roll them node-side — consistent with how PTC/preferences/the vote were done — shaped to the pinned consensus-spec and cross-checked against go-eth2-client PR #280 (PR #269 is stale: its ExecutionPayloadBid predates the blob-commitments-list change and its PayloadAttestation uses a Bitlist where the spec says Bitvector[PTC_SIZE]). - execution_payload_bid.go: BuilderIndex (+ the self-build sentinel), ExecutionPayloadBid (the ePBS commitment that replaces the inline execution payload), SignedExecutionPayloadBid. - beacon_block.go: PayloadAttestation (the aggregated, Bitvector[512] form the block carries — distinct from the single-member PayloadAttestationMessage SSV signs in PTC), BeaconBlockBody (reuses the existing fork types plus the bid, payload attestations, and parent execution requests), BeaconBlock, SignedBeaconBlock. SSZ via sszgen; the body resolves its sibling gloas types with -path . + --exclude-objs + --output so their encodings aren't re-emitted. Round-trip and HashTreeRoot tests included. Not wired into the proposer path yet (T7b/T7c). --- protocol/v2/types/gloas/beacon_block.go | 66 ++ .../v2/types/gloas/beacon_block_encoding.go | 965 ++++++++++++++++++ protocol/v2/types/gloas/beacon_block_test.go | 48 + .../v2/types/gloas/execution_payload_bid.go | 45 + .../gloas/execution_payload_bid_encoding.go | 310 ++++++ .../types/gloas/execution_payload_bid_test.go | 30 + 6 files changed, 1464 insertions(+) create mode 100644 protocol/v2/types/gloas/beacon_block.go create mode 100644 protocol/v2/types/gloas/beacon_block_encoding.go create mode 100644 protocol/v2/types/gloas/beacon_block_test.go create mode 100644 protocol/v2/types/gloas/execution_payload_bid.go create mode 100644 protocol/v2/types/gloas/execution_payload_bid_encoding.go create mode 100644 protocol/v2/types/gloas/execution_payload_bid_test.go diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go new file mode 100644 index 0000000000..5982bc87f0 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block.go @@ -0,0 +1,66 @@ +package gloas + +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/phase0" + bitfield "github.com/prysmaticlabs/go-bitfield" +) + +// Regenerate with `go generate ./...`. -path is the package dir so sszgen resolves the sibling gloas +// types the body references; --exclude-objs leaves their (already-generated) SSZ in their own files, +// and --output collects only the body types here. Includes track go-eth2-client via `go list -m`. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/altair,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/deneb --objs PayloadAttestation,BeaconBlockBody,BeaconBlock,SignedBeaconBlock --exclude-objs ExecutionPayloadBid,SignedExecutionPayloadBid,PayloadAttestationData --output ./beacon_block_encoding.go" + +// PayloadAttestation is the aggregated PTC attestation the proposer includes in the block body for the +// previous slot's payload (consensus-specs gloas) — distinct from the single-member +// PayloadAttestationMessage SSV signs in §3. AggregationBits is a Bitvector[PTC_SIZE], PTC_SIZE = 512. +type PayloadAttestation struct { + AggregationBits bitfield.Bitvector512 `ssz-size:"64"` + Data *PayloadAttestationData + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// BeaconBlockBody is the Gloas (ePBS) block body. Versus Electra it drops the inline execution payload +// and execution requests and adds SignedExecutionPayloadBid (the payload commitment), PayloadAttestations +// (the previous slot's PTC aggregate), and ParentExecutionRequests. Field order/tags match the pinned +// spec / go-eth2-client PR #280; everything else reuses the existing fork types. +type BeaconBlockBody struct { + RANDAOReveal phase0.BLSSignature `ssz-size:"96"` + ETH1Data *phase0.ETH1Data + Graffiti [32]byte `ssz-size:"32"` + ProposerSlashings []*phase0.ProposerSlashing `ssz-max:"16"` + AttesterSlashings []*electra.AttesterSlashing `ssz-max:"1"` + Attestations []*electra.Attestation `ssz-max:"8"` + Deposits []*phase0.Deposit `ssz-max:"16"` + VoluntaryExits []*phase0.SignedVoluntaryExit `ssz-max:"16"` + SyncAggregate *altair.SyncAggregate + BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `ssz-max:"16"` + SignedExecutionPayloadBid *SignedExecutionPayloadBid + PayloadAttestations []*PayloadAttestation `ssz-max:"4"` + ParentExecutionRequests *electra.ExecutionRequests +} + +// BeaconBlock is the Gloas (ePBS) beacon block. +type BeaconBlock struct { + Slot phase0.Slot + ProposerIndex phase0.ValidatorIndex + ParentRoot phase0.Root `ssz-size:"32"` + StateRoot phase0.Root `ssz-size:"32"` + Body *BeaconBlockBody +} + +// SignedBeaconBlock wraps a Gloas BeaconBlock with the proposer's signature. +type SignedBeaconBlock struct { + Message *BeaconBlock + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// Encode/Decode are the convenience wrappers the proposer runner uses to marshal the block into the +// QBFT DataSSZ and to publish the signed block. +func (b *BeaconBlock) Encode() ([]byte, error) { return b.MarshalSSZ() } +func (b *BeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } + +func (b *SignedBeaconBlock) Encode() ([]byte, error) { return b.MarshalSSZ() } +func (b *SignedBeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } diff --git a/protocol/v2/types/gloas/beacon_block_encoding.go b/protocol/v2/types/gloas/beacon_block_encoding.go new file mode 100644 index 0000000000..3cc9f1e9ff --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block_encoding.go @@ -0,0 +1,965 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 7efcf94f53628815916fc52c0883d2d4262466619f93c2e0ad15e1c60823ded5 +// Version: 0.1.3 +package gloas + +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/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the PayloadAttestation object +func (p *PayloadAttestation) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(p) +} + +// MarshalSSZTo ssz marshals the PayloadAttestation object to a target array +func (p *PayloadAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'AggregationBits' + if size := len(p.AggregationBits); size != 64 { + err = ssz.ErrBytesLengthFn("PayloadAttestation.AggregationBits", size, 64) + return + } + dst = append(dst, p.AggregationBits...) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if dst, err = p.Data.MarshalSSZTo(dst); err != nil { + return + } + + // Field (2) 'Signature' + dst = append(dst, p.Signature[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the PayloadAttestation object +func (p *PayloadAttestation) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 202 { + return ssz.ErrSize + } + + // Field (0) 'AggregationBits' + if cap(p.AggregationBits) == 0 { + p.AggregationBits = make([]byte, 0, len(buf[0:64])) + } + p.AggregationBits = append(p.AggregationBits, buf[0:64]...) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if err = p.Data.UnmarshalSSZ(buf[64:106]); err != nil { + return err + } + + // Field (2) 'Signature' + copy(p.Signature[:], buf[106:202]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the PayloadAttestation object +func (p *PayloadAttestation) SizeSSZ() (size int) { + size = 202 + return +} + +// HashTreeRoot ssz hashes the PayloadAttestation object +func (p *PayloadAttestation) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(p) +} + +// HashTreeRootWith ssz hashes the PayloadAttestation object with a hasher +func (p *PayloadAttestation) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'AggregationBits' + if size := len(p.AggregationBits); size != 64 { + err = ssz.ErrBytesLengthFn("PayloadAttestation.AggregationBits", size, 64) + return + } + hh.PutBytes(p.AggregationBits) + + // Field (1) 'Data' + if p.Data == nil { + p.Data = new(PayloadAttestationData) + } + if err = p.Data.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'Signature' + hh.PutBytes(p.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the PayloadAttestation object +func (p *PayloadAttestation) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(p) +} + +// MarshalSSZ ssz marshals the BeaconBlockBody object +func (b *BeaconBlockBody) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(b) +} + +// MarshalSSZTo ssz marshals the BeaconBlockBody object to a target array +func (b *BeaconBlockBody) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(396) + + // Field (0) 'RANDAOReveal' + dst = append(dst, b.RANDAOReveal[:]...) + + // Field (1) 'ETH1Data' + if b.ETH1Data == nil { + b.ETH1Data = new(phase0.ETH1Data) + } + if dst, err = b.ETH1Data.MarshalSSZTo(dst); err != nil { + return + } + + // Field (2) 'Graffiti' + dst = append(dst, b.Graffiti[:]...) + + // Offset (3) 'ProposerSlashings' + dst = ssz.WriteOffset(dst, offset) + offset += len(b.ProposerSlashings) * 416 + + // Offset (4) 'AttesterSlashings' + dst = ssz.WriteOffset(dst, offset) + for ii := 0; ii < len(b.AttesterSlashings); ii++ { + offset += 4 + offset += b.AttesterSlashings[ii].SizeSSZ() + } + + // Offset (5) 'Attestations' + dst = ssz.WriteOffset(dst, offset) + for ii := 0; ii < len(b.Attestations); ii++ { + offset += 4 + offset += b.Attestations[ii].SizeSSZ() + } + + // Offset (6) 'Deposits' + dst = ssz.WriteOffset(dst, offset) + offset += len(b.Deposits) * 1240 + + // Offset (7) 'VoluntaryExits' + dst = ssz.WriteOffset(dst, offset) + offset += len(b.VoluntaryExits) * 112 + + // Field (8) 'SyncAggregate' + if b.SyncAggregate == nil { + b.SyncAggregate = new(altair.SyncAggregate) + } + if dst, err = b.SyncAggregate.MarshalSSZTo(dst); err != nil { + return + } + + // Offset (9) 'BLSToExecutionChanges' + dst = ssz.WriteOffset(dst, offset) + offset += len(b.BLSToExecutionChanges) * 172 + + // Offset (10) 'SignedExecutionPayloadBid' + dst = ssz.WriteOffset(dst, offset) + if b.SignedExecutionPayloadBid == nil { + b.SignedExecutionPayloadBid = new(SignedExecutionPayloadBid) + } + offset += b.SignedExecutionPayloadBid.SizeSSZ() + + // Offset (11) 'PayloadAttestations' + dst = ssz.WriteOffset(dst, offset) + offset += len(b.PayloadAttestations) * 202 + + // Offset (12) 'ParentExecutionRequests' + dst = ssz.WriteOffset(dst, offset) + + // Field (3) 'ProposerSlashings' + if size := len(b.ProposerSlashings); size > 16 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.ProposerSlashings", size, 16) + return + } + for ii := 0; ii < len(b.ProposerSlashings); ii++ { + if dst, err = b.ProposerSlashings[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (4) 'AttesterSlashings' + if size := len(b.AttesterSlashings); size > 1 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.AttesterSlashings", size, 1) + return + } + { + offset = 4 * len(b.AttesterSlashings) + for ii := 0; ii < len(b.AttesterSlashings); ii++ { + dst = ssz.WriteOffset(dst, offset) + offset += b.AttesterSlashings[ii].SizeSSZ() + } + } + for ii := 0; ii < len(b.AttesterSlashings); ii++ { + if dst, err = b.AttesterSlashings[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (5) 'Attestations' + if size := len(b.Attestations); size > 8 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.Attestations", size, 8) + return + } + { + offset = 4 * len(b.Attestations) + for ii := 0; ii < len(b.Attestations); ii++ { + dst = ssz.WriteOffset(dst, offset) + offset += b.Attestations[ii].SizeSSZ() + } + } + for ii := 0; ii < len(b.Attestations); ii++ { + if dst, err = b.Attestations[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (6) 'Deposits' + if size := len(b.Deposits); size > 16 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.Deposits", size, 16) + return + } + for ii := 0; ii < len(b.Deposits); ii++ { + if dst, err = b.Deposits[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (7) 'VoluntaryExits' + if size := len(b.VoluntaryExits); size > 16 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.VoluntaryExits", size, 16) + return + } + for ii := 0; ii < len(b.VoluntaryExits); ii++ { + if dst, err = b.VoluntaryExits[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (9) 'BLSToExecutionChanges' + if size := len(b.BLSToExecutionChanges); size > 16 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.BLSToExecutionChanges", size, 16) + return + } + for ii := 0; ii < len(b.BLSToExecutionChanges); ii++ { + if dst, err = b.BLSToExecutionChanges[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (10) 'SignedExecutionPayloadBid' + if dst, err = b.SignedExecutionPayloadBid.MarshalSSZTo(dst); err != nil { + return + } + + // Field (11) 'PayloadAttestations' + if size := len(b.PayloadAttestations); size > 4 { + err = ssz.ErrListTooBigFn("BeaconBlockBody.PayloadAttestations", size, 4) + return + } + for ii := 0; ii < len(b.PayloadAttestations); ii++ { + if dst, err = b.PayloadAttestations[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (12) 'ParentExecutionRequests' + if dst, err = b.ParentExecutionRequests.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the BeaconBlockBody object +func (b *BeaconBlockBody) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 396 { + return ssz.ErrSize + } + + tail := buf + var o3, o4, o5, o6, o7, o9, o10, o11, o12 uint64 + + // Field (0) 'RANDAOReveal' + copy(b.RANDAOReveal[:], buf[0:96]) + + // Field (1) 'ETH1Data' + if b.ETH1Data == nil { + b.ETH1Data = new(phase0.ETH1Data) + } + if err = b.ETH1Data.UnmarshalSSZ(buf[96:168]); err != nil { + return err + } + + // Field (2) 'Graffiti' + copy(b.Graffiti[:], buf[168:200]) + + // Offset (3) 'ProposerSlashings' + if o3 = ssz.ReadOffset(buf[200:204]); o3 > size { + return ssz.ErrOffset + } + + if o3 != 396 { + return ssz.ErrInvalidVariableOffset + } + + // Offset (4) 'AttesterSlashings' + if o4 = ssz.ReadOffset(buf[204:208]); o4 > size || o3 > o4 { + return ssz.ErrOffset + } + + // Offset (5) 'Attestations' + if o5 = ssz.ReadOffset(buf[208:212]); o5 > size || o4 > o5 { + return ssz.ErrOffset + } + + // Offset (6) 'Deposits' + if o6 = ssz.ReadOffset(buf[212:216]); o6 > size || o5 > o6 { + return ssz.ErrOffset + } + + // Offset (7) 'VoluntaryExits' + if o7 = ssz.ReadOffset(buf[216:220]); o7 > size || o6 > o7 { + return ssz.ErrOffset + } + + // Field (8) 'SyncAggregate' + if b.SyncAggregate == nil { + b.SyncAggregate = new(altair.SyncAggregate) + } + if err = b.SyncAggregate.UnmarshalSSZ(buf[220:380]); err != nil { + return err + } + + // Offset (9) 'BLSToExecutionChanges' + if o9 = ssz.ReadOffset(buf[380:384]); o9 > size || o7 > o9 { + return ssz.ErrOffset + } + + // Offset (10) 'SignedExecutionPayloadBid' + if o10 = ssz.ReadOffset(buf[384:388]); o10 > size || o9 > o10 { + return ssz.ErrOffset + } + + // Offset (11) 'PayloadAttestations' + if o11 = ssz.ReadOffset(buf[388:392]); o11 > size || o10 > o11 { + return ssz.ErrOffset + } + + // Offset (12) 'ParentExecutionRequests' + if o12 = ssz.ReadOffset(buf[392:396]); o12 > size || o11 > o12 { + return ssz.ErrOffset + } + + // Field (3) 'ProposerSlashings' + { + buf = tail[o3:o4] + num, err := ssz.DivideInt2(len(buf), 416, 16) + if err != nil { + return err + } + b.ProposerSlashings = make([]*phase0.ProposerSlashing, num) + for ii := 0; ii < num; ii++ { + if b.ProposerSlashings[ii] == nil { + b.ProposerSlashings[ii] = new(phase0.ProposerSlashing) + } + if err = b.ProposerSlashings[ii].UnmarshalSSZ(buf[ii*416 : (ii+1)*416]); err != nil { + return err + } + } + } + + // Field (4) 'AttesterSlashings' + { + buf = tail[o4:o5] + num, err := ssz.DecodeDynamicLength(buf, 1) + if err != nil { + return err + } + b.AttesterSlashings = make([]*electra.AttesterSlashing, num) + err = ssz.UnmarshalDynamic(buf, num, func(indx int, buf []byte) (err error) { + if b.AttesterSlashings[indx] == nil { + b.AttesterSlashings[indx] = new(electra.AttesterSlashing) + } + if err = b.AttesterSlashings[indx].UnmarshalSSZ(buf); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + } + + // Field (5) 'Attestations' + { + buf = tail[o5:o6] + num, err := ssz.DecodeDynamicLength(buf, 8) + if err != nil { + return err + } + b.Attestations = make([]*electra.Attestation, num) + err = ssz.UnmarshalDynamic(buf, num, func(indx int, buf []byte) (err error) { + if b.Attestations[indx] == nil { + b.Attestations[indx] = new(electra.Attestation) + } + if err = b.Attestations[indx].UnmarshalSSZ(buf); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + } + + // Field (6) 'Deposits' + { + buf = tail[o6:o7] + num, err := ssz.DivideInt2(len(buf), 1240, 16) + if err != nil { + return err + } + b.Deposits = make([]*phase0.Deposit, num) + for ii := 0; ii < num; ii++ { + if b.Deposits[ii] == nil { + b.Deposits[ii] = new(phase0.Deposit) + } + if err = b.Deposits[ii].UnmarshalSSZ(buf[ii*1240 : (ii+1)*1240]); err != nil { + return err + } + } + } + + // Field (7) 'VoluntaryExits' + { + buf = tail[o7:o9] + num, err := ssz.DivideInt2(len(buf), 112, 16) + if err != nil { + return err + } + b.VoluntaryExits = make([]*phase0.SignedVoluntaryExit, num) + for ii := 0; ii < num; ii++ { + if b.VoluntaryExits[ii] == nil { + b.VoluntaryExits[ii] = new(phase0.SignedVoluntaryExit) + } + if err = b.VoluntaryExits[ii].UnmarshalSSZ(buf[ii*112 : (ii+1)*112]); err != nil { + return err + } + } + } + + // Field (9) 'BLSToExecutionChanges' + { + buf = tail[o9:o10] + num, err := ssz.DivideInt2(len(buf), 172, 16) + if err != nil { + return err + } + b.BLSToExecutionChanges = make([]*capella.SignedBLSToExecutionChange, num) + for ii := 0; ii < num; ii++ { + if b.BLSToExecutionChanges[ii] == nil { + b.BLSToExecutionChanges[ii] = new(capella.SignedBLSToExecutionChange) + } + if err = b.BLSToExecutionChanges[ii].UnmarshalSSZ(buf[ii*172 : (ii+1)*172]); err != nil { + return err + } + } + } + + // Field (10) 'SignedExecutionPayloadBid' + { + buf = tail[o10:o11] + if b.SignedExecutionPayloadBid == nil { + b.SignedExecutionPayloadBid = new(SignedExecutionPayloadBid) + } + if err = b.SignedExecutionPayloadBid.UnmarshalSSZ(buf); err != nil { + return err + } + } + + // Field (11) 'PayloadAttestations' + { + buf = tail[o11:o12] + num, err := ssz.DivideInt2(len(buf), 202, 4) + if err != nil { + return err + } + b.PayloadAttestations = make([]*PayloadAttestation, num) + for ii := 0; ii < num; ii++ { + if b.PayloadAttestations[ii] == nil { + b.PayloadAttestations[ii] = new(PayloadAttestation) + } + if err = b.PayloadAttestations[ii].UnmarshalSSZ(buf[ii*202 : (ii+1)*202]); err != nil { + return err + } + } + } + + // Field (12) 'ParentExecutionRequests' + { + buf = tail[o12:] + if b.ParentExecutionRequests == nil { + b.ParentExecutionRequests = new(electra.ExecutionRequests) + } + if err = b.ParentExecutionRequests.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the BeaconBlockBody object +func (b *BeaconBlockBody) SizeSSZ() (size int) { + size = 396 + + // Field (3) 'ProposerSlashings' + size += len(b.ProposerSlashings) * 416 + + // Field (4) 'AttesterSlashings' + for ii := 0; ii < len(b.AttesterSlashings); ii++ { + size += 4 + size += b.AttesterSlashings[ii].SizeSSZ() + } + + // Field (5) 'Attestations' + for ii := 0; ii < len(b.Attestations); ii++ { + size += 4 + size += b.Attestations[ii].SizeSSZ() + } + + // Field (6) 'Deposits' + size += len(b.Deposits) * 1240 + + // Field (7) 'VoluntaryExits' + size += len(b.VoluntaryExits) * 112 + + // Field (9) 'BLSToExecutionChanges' + size += len(b.BLSToExecutionChanges) * 172 + + // Field (10) 'SignedExecutionPayloadBid' + if b.SignedExecutionPayloadBid == nil { + b.SignedExecutionPayloadBid = new(SignedExecutionPayloadBid) + } + size += b.SignedExecutionPayloadBid.SizeSSZ() + + // Field (11) 'PayloadAttestations' + size += len(b.PayloadAttestations) * 202 + + // Field (12) 'ParentExecutionRequests' + if b.ParentExecutionRequests == nil { + b.ParentExecutionRequests = new(electra.ExecutionRequests) + } + size += b.ParentExecutionRequests.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the BeaconBlockBody object +func (b *BeaconBlockBody) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(b) +} + +// HashTreeRootWith ssz hashes the BeaconBlockBody object with a hasher +func (b *BeaconBlockBody) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'RANDAOReveal' + hh.PutBytes(b.RANDAOReveal[:]) + + // Field (1) 'ETH1Data' + if b.ETH1Data == nil { + b.ETH1Data = new(phase0.ETH1Data) + } + if err = b.ETH1Data.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'Graffiti' + hh.PutBytes(b.Graffiti[:]) + + // Field (3) 'ProposerSlashings' + { + subIndx := hh.Index() + num := uint64(len(b.ProposerSlashings)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.ProposerSlashings { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (4) 'AttesterSlashings' + { + subIndx := hh.Index() + num := uint64(len(b.AttesterSlashings)) + if num > 1 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.AttesterSlashings { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 1) + } + + // Field (5) 'Attestations' + { + subIndx := hh.Index() + num := uint64(len(b.Attestations)) + if num > 8 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.Attestations { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 8) + } + + // Field (6) 'Deposits' + { + subIndx := hh.Index() + num := uint64(len(b.Deposits)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.Deposits { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (7) 'VoluntaryExits' + { + subIndx := hh.Index() + num := uint64(len(b.VoluntaryExits)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.VoluntaryExits { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (8) 'SyncAggregate' + if b.SyncAggregate == nil { + b.SyncAggregate = new(altair.SyncAggregate) + } + if err = b.SyncAggregate.HashTreeRootWith(hh); err != nil { + return + } + + // Field (9) 'BLSToExecutionChanges' + { + subIndx := hh.Index() + num := uint64(len(b.BLSToExecutionChanges)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.BLSToExecutionChanges { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (10) 'SignedExecutionPayloadBid' + if err = b.SignedExecutionPayloadBid.HashTreeRootWith(hh); err != nil { + return + } + + // Field (11) 'PayloadAttestations' + { + subIndx := hh.Index() + num := uint64(len(b.PayloadAttestations)) + if num > 4 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range b.PayloadAttestations { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 4) + } + + // Field (12) 'ParentExecutionRequests' + if err = b.ParentExecutionRequests.HashTreeRootWith(hh); err != nil { + return + } + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the BeaconBlockBody object +func (b *BeaconBlockBody) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(b) +} + +// MarshalSSZ ssz marshals the BeaconBlock object +func (b *BeaconBlock) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(b) +} + +// MarshalSSZTo ssz marshals the BeaconBlock object to a target array +func (b *BeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(84) + + // Field (0) 'Slot' + dst = ssz.MarshalUint64(dst, uint64(b.Slot)) + + // Field (1) 'ProposerIndex' + dst = ssz.MarshalUint64(dst, uint64(b.ProposerIndex)) + + // Field (2) 'ParentRoot' + dst = append(dst, b.ParentRoot[:]...) + + // Field (3) 'StateRoot' + dst = append(dst, b.StateRoot[:]...) + + // Offset (4) 'Body' + dst = ssz.WriteOffset(dst, offset) + + // Field (4) 'Body' + if dst, err = b.Body.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the BeaconBlock object +func (b *BeaconBlock) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 84 { + return ssz.ErrSize + } + + tail := buf + var o4 uint64 + + // Field (0) 'Slot' + b.Slot = phase0.Slot(ssz.UnmarshallUint64(buf[0:8])) + + // Field (1) 'ProposerIndex' + b.ProposerIndex = phase0.ValidatorIndex(ssz.UnmarshallUint64(buf[8:16])) + + // Field (2) 'ParentRoot' + copy(b.ParentRoot[:], buf[16:48]) + + // Field (3) 'StateRoot' + copy(b.StateRoot[:], buf[48:80]) + + // Offset (4) 'Body' + if o4 = ssz.ReadOffset(buf[80:84]); o4 > size { + return ssz.ErrOffset + } + + if o4 != 84 { + return ssz.ErrInvalidVariableOffset + } + + // Field (4) 'Body' + { + buf = tail[o4:] + if b.Body == nil { + b.Body = new(BeaconBlockBody) + } + if err = b.Body.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the BeaconBlock object +func (b *BeaconBlock) SizeSSZ() (size int) { + size = 84 + + // Field (4) 'Body' + if b.Body == nil { + b.Body = new(BeaconBlockBody) + } + size += b.Body.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the BeaconBlock object +func (b *BeaconBlock) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(b) +} + +// HashTreeRootWith ssz hashes the BeaconBlock object with a hasher +func (b *BeaconBlock) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Slot' + hh.PutUint64(uint64(b.Slot)) + + // Field (1) 'ProposerIndex' + hh.PutUint64(uint64(b.ProposerIndex)) + + // Field (2) 'ParentRoot' + hh.PutBytes(b.ParentRoot[:]) + + // Field (3) 'StateRoot' + hh.PutBytes(b.StateRoot[:]) + + // Field (4) 'Body' + if err = b.Body.HashTreeRootWith(hh); err != nil { + return + } + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the BeaconBlock object +func (b *BeaconBlock) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(b) +} + +// MarshalSSZ ssz marshals the SignedBeaconBlock object +func (s *SignedBeaconBlock) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedBeaconBlock object to a target array +func (s *SignedBeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(100) + + // Offset (0) 'Message' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + // Field (0) 'Message' + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedBeaconBlock object +func (s *SignedBeaconBlock) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 100 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Message' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 100 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[4:100]) + + // Field (0) 'Message' + { + buf = tail[o0:] + if s.Message == nil { + s.Message = new(BeaconBlock) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedBeaconBlock object +func (s *SignedBeaconBlock) SizeSSZ() (size int) { + size = 100 + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(BeaconBlock) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedBeaconBlock object +func (s *SignedBeaconBlock) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedBeaconBlock object with a hasher +func (s *SignedBeaconBlock) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedBeaconBlock object +func (s *SignedBeaconBlock) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/beacon_block_test.go b/protocol/v2/types/gloas/beacon_block_test.go new file mode 100644 index 0000000000..e1784ddb9c --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block_test.go @@ -0,0 +1,48 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + bitfield "github.com/prysmaticlabs/go-bitfield" + "github.com/stretchr/testify/require" +) + +// A Gloas block round-trips through SSZ with the ePBS-specific body fields populated (the payload bid, +// an aggregated payload attestation, and the parent execution requests), and its root is stable. +func TestSignedBeaconBlockRoundTrip(t *testing.T) { + in := &SignedBeaconBlock{Message: &BeaconBlock{ + Slot: 7, + ProposerIndex: 3, + Body: &BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &SignedExecutionPayloadBid{Message: &ExecutionPayloadBid{ + BuilderIndex: BuilderIndexSelfBuild, + BlobKZGCommitments: []deneb.KZGCommitment{{0x01}}, + }}, + PayloadAttestations: []*PayloadAttestation{{ + AggregationBits: bitfield.NewBitvector512(), + Data: &PayloadAttestationData{Slot: 6, PayloadPresent: true}, + }}, + ParentExecutionRequests: &electra.ExecutionRequests{}, + }, + }} + b, err := in.MarshalSSZ() + require.NoError(t, err) + + out := &SignedBeaconBlock{} + require.NoError(t, out.UnmarshalSSZ(b)) + require.Equal(t, in.Message.Slot, out.Message.Slot) + require.Equal(t, BuilderIndexSelfBuild, out.Message.Body.SignedExecutionPayloadBid.Message.BuilderIndex) + require.True(t, out.Message.Body.PayloadAttestations[0].Data.PayloadPresent) + + r1, err := in.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} diff --git a/protocol/v2/types/gloas/execution_payload_bid.go b/protocol/v2/types/gloas/execution_payload_bid.go new file mode 100644 index 0000000000..2f07cd18bc --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_bid.go @@ -0,0 +1,45 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. The includes are resolved from the module graph (`go list -m`) +// so they track go-eth2-client across dependency bumps rather than pinning. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./execution_payload_bid.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/deneb --objs ExecutionPayloadBid,SignedExecutionPayloadBid" + +// BuilderIndex identifies a builder in the Gloas builder registry. The sentinel BuilderIndexSelfBuild +// (BUILDER_INDEX_SELF_BUILD = UINT64_MAX) marks a self-built payload — the only path SSV produces. +type BuilderIndex uint64 + +// BuilderIndexSelfBuild (BUILDER_INDEX_SELF_BUILD) flags a self-built execution payload (SIP #94 §4). +const BuilderIndexSelfBuild = BuilderIndex(^uint64(0)) + +// ExecutionPayloadBid is the Gloas (ePBS) bid the proposer commits to in the block body, replacing the +// inline execution payload of pre-Gloas blocks (consensus-specs gloas): the block carries only this +// commitment, and the payload itself ships separately in the envelope (§6). For self-build, BuilderIndex +// is BuilderIndexSelfBuild. Fields match the pinned spec / go-eth2-client PR #280 (the earlier #269 +// shape — a single BlobKZGCommitmentsRoot — predates the blob-commitments-list change and is stale). +type ExecutionPayloadBid struct { + ParentBlockHash phase0.Hash32 `ssz-size:"32"` + ParentBlockRoot phase0.Root `ssz-size:"32"` + BlockHash phase0.Hash32 `ssz-size:"32"` + PrevRandao phase0.Root `ssz-size:"32"` + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + GasLimit uint64 + BuilderIndex BuilderIndex + Slot phase0.Slot + Value phase0.Gwei + ExecutionPayment phase0.Gwei + BlobKZGCommitments []deneb.KZGCommitment `ssz-max:"4096" ssz-size:"?,48"` + ExecutionRequestsRoot phase0.Root `ssz-size:"32"` +} + +// SignedExecutionPayloadBid wraps an ExecutionPayloadBid with the builder's (or, for self-build, the +// proposer's) signature. +type SignedExecutionPayloadBid struct { + Message *ExecutionPayloadBid + Signature phase0.BLSSignature `ssz-size:"96"` +} diff --git a/protocol/v2/types/gloas/execution_payload_bid_encoding.go b/protocol/v2/types/gloas/execution_payload_bid_encoding.go new file mode 100644 index 0000000000..7cc02bf46a --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_bid_encoding.go @@ -0,0 +1,310 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: d920b162beb9c1bebf50a7f000cc16d5686f314a330bb3a3520d5c1932290c94 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the ExecutionPayloadBid object +func (e *ExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(e) +} + +// MarshalSSZTo ssz marshals the ExecutionPayloadBid object to a target array +func (e *ExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(224) + + // Field (0) 'ParentBlockHash' + dst = append(dst, e.ParentBlockHash[:]...) + + // Field (1) 'ParentBlockRoot' + dst = append(dst, e.ParentBlockRoot[:]...) + + // Field (2) 'BlockHash' + dst = append(dst, e.BlockHash[:]...) + + // Field (3) 'PrevRandao' + dst = append(dst, e.PrevRandao[:]...) + + // Field (4) 'FeeRecipient' + dst = append(dst, e.FeeRecipient[:]...) + + // Field (5) 'GasLimit' + dst = ssz.MarshalUint64(dst, e.GasLimit) + + // Field (6) 'BuilderIndex' + dst = ssz.MarshalUint64(dst, uint64(e.BuilderIndex)) + + // Field (7) 'Slot' + dst = ssz.MarshalUint64(dst, uint64(e.Slot)) + + // Field (8) 'Value' + dst = ssz.MarshalUint64(dst, uint64(e.Value)) + + // Field (9) 'ExecutionPayment' + dst = ssz.MarshalUint64(dst, uint64(e.ExecutionPayment)) + + // Offset (10) 'BlobKZGCommitments' + dst = ssz.WriteOffset(dst, offset) + + // Field (11) 'ExecutionRequestsRoot' + dst = append(dst, e.ExecutionRequestsRoot[:]...) + + // Field (10) 'BlobKZGCommitments' + if size := len(e.BlobKZGCommitments); size > 4096 { + err = ssz.ErrListTooBigFn("ExecutionPayloadBid.BlobKZGCommitments", size, 4096) + return + } + for ii := 0; ii < len(e.BlobKZGCommitments); ii++ { + dst = append(dst, e.BlobKZGCommitments[ii][:]...) + } + + return +} + +// UnmarshalSSZ ssz unmarshals the ExecutionPayloadBid object +func (e *ExecutionPayloadBid) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 224 { + return ssz.ErrSize + } + + tail := buf + var o10 uint64 + + // Field (0) 'ParentBlockHash' + copy(e.ParentBlockHash[:], buf[0:32]) + + // Field (1) 'ParentBlockRoot' + copy(e.ParentBlockRoot[:], buf[32:64]) + + // Field (2) 'BlockHash' + copy(e.BlockHash[:], buf[64:96]) + + // Field (3) 'PrevRandao' + copy(e.PrevRandao[:], buf[96:128]) + + // Field (4) 'FeeRecipient' + copy(e.FeeRecipient[:], buf[128:148]) + + // Field (5) 'GasLimit' + e.GasLimit = ssz.UnmarshallUint64(buf[148:156]) + + // Field (6) 'BuilderIndex' + e.BuilderIndex = BuilderIndex(ssz.UnmarshallUint64(buf[156:164])) + + // Field (7) 'Slot' + e.Slot = phase0.Slot(ssz.UnmarshallUint64(buf[164:172])) + + // Field (8) 'Value' + e.Value = phase0.Gwei(ssz.UnmarshallUint64(buf[172:180])) + + // Field (9) 'ExecutionPayment' + e.ExecutionPayment = phase0.Gwei(ssz.UnmarshallUint64(buf[180:188])) + + // Offset (10) 'BlobKZGCommitments' + if o10 = ssz.ReadOffset(buf[188:192]); o10 > size { + return ssz.ErrOffset + } + + if o10 != 224 { + return ssz.ErrInvalidVariableOffset + } + + // Field (11) 'ExecutionRequestsRoot' + copy(e.ExecutionRequestsRoot[:], buf[192:224]) + + // Field (10) 'BlobKZGCommitments' + { + buf = tail[o10:] + num, err := ssz.DivideInt2(len(buf), 48, 4096) + if err != nil { + return err + } + e.BlobKZGCommitments = make([]deneb.KZGCommitment, num) + for ii := 0; ii < num; ii++ { + copy(e.BlobKZGCommitments[ii][:], buf[ii*48:(ii+1)*48]) + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the ExecutionPayloadBid object +func (e *ExecutionPayloadBid) SizeSSZ() (size int) { + size = 224 + + // Field (10) 'BlobKZGCommitments' + size += len(e.BlobKZGCommitments) * 48 + + return +} + +// HashTreeRoot ssz hashes the ExecutionPayloadBid object +func (e *ExecutionPayloadBid) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(e) +} + +// HashTreeRootWith ssz hashes the ExecutionPayloadBid object with a hasher +func (e *ExecutionPayloadBid) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'ParentBlockHash' + hh.PutBytes(e.ParentBlockHash[:]) + + // Field (1) 'ParentBlockRoot' + hh.PutBytes(e.ParentBlockRoot[:]) + + // Field (2) 'BlockHash' + hh.PutBytes(e.BlockHash[:]) + + // Field (3) 'PrevRandao' + hh.PutBytes(e.PrevRandao[:]) + + // Field (4) 'FeeRecipient' + hh.PutBytes(e.FeeRecipient[:]) + + // Field (5) 'GasLimit' + hh.PutUint64(e.GasLimit) + + // Field (6) 'BuilderIndex' + hh.PutUint64(uint64(e.BuilderIndex)) + + // Field (7) 'Slot' + hh.PutUint64(uint64(e.Slot)) + + // Field (8) 'Value' + hh.PutUint64(uint64(e.Value)) + + // Field (9) 'ExecutionPayment' + hh.PutUint64(uint64(e.ExecutionPayment)) + + // Field (10) 'BlobKZGCommitments' + { + if size := len(e.BlobKZGCommitments); size > 4096 { + err = ssz.ErrListTooBigFn("ExecutionPayloadBid.BlobKZGCommitments", size, 4096) + return + } + subIndx := hh.Index() + for _, i := range e.BlobKZGCommitments { + hh.PutBytes(i[:]) + } + numItems := uint64(len(e.BlobKZGCommitments)) + hh.MerkleizeWithMixin(subIndx, numItems, 4096) + } + + // Field (11) 'ExecutionRequestsRoot' + hh.PutBytes(e.ExecutionRequestsRoot[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the ExecutionPayloadBid object +func (e *ExecutionPayloadBid) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(e) +} + +// MarshalSSZ ssz marshals the SignedExecutionPayloadBid object +func (s *SignedExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedExecutionPayloadBid object to a target array +func (s *SignedExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(100) + + // Offset (0) 'Message' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + // Field (0) 'Message' + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedExecutionPayloadBid object +func (s *SignedExecutionPayloadBid) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 100 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Message' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 100 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[4:100]) + + // Field (0) 'Message' + { + buf = tail[o0:] + if s.Message == nil { + s.Message = new(ExecutionPayloadBid) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedExecutionPayloadBid object +func (s *SignedExecutionPayloadBid) SizeSSZ() (size int) { + size = 100 + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(ExecutionPayloadBid) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedExecutionPayloadBid object +func (s *SignedExecutionPayloadBid) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedExecutionPayloadBid object with a hasher +func (s *SignedExecutionPayloadBid) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedExecutionPayloadBid object +func (s *SignedExecutionPayloadBid) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/execution_payload_bid_test.go b/protocol/v2/types/gloas/execution_payload_bid_test.go new file mode 100644 index 0000000000..fc5f34e142 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_bid_test.go @@ -0,0 +1,30 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadBidRoundTrip(t *testing.T) { + in := &SignedExecutionPayloadBid{Message: &ExecutionPayloadBid{ + BlockHash: [32]byte{0xaa}, + BuilderIndex: BuilderIndexSelfBuild, + Value: 123, + BlobKZGCommitments: []deneb.KZGCommitment{{0x01}, {0x02}}, + }} + b, err := in.MarshalSSZ() + require.NoError(t, err) + + out := &SignedExecutionPayloadBid{} + require.NoError(t, out.UnmarshalSSZ(b)) + require.Equal(t, BuilderIndexSelfBuild, out.Message.BuilderIndex) + require.Len(t, out.Message.BlobKZGCommitments, 2) + + r1, err := in.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} From 4a8c10692251ddf5d0a5a5dc75c8201c34fe97cb Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 19:25:19 +0300 Subject: [PATCH 035/150] =?UTF-8?q?gloas:=20add=20goclient=20produce/publi?= =?UTF-8?q?sh=20for=20Gloas=20blocks=20(SIP=20#94=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-eth2-client has no Gloas types, so the proposer path cannot produce or publish ePBS blocks through api.VersionedProposal. Add a GloasProposerCalls beacon-node surface — GetGloasBeaconBlock / SubmitGloasBeaconBlock — hand-rolled as SSZ-over-HTTP (the block types are SSZ-only) reusing the PTC raw-HTTP client: GET the produce endpoint and decode into the node-side gloas.BeaconBlock, POST the signed block tagged with Eth-Consensus-Version: gloas. The produce/publish path and headers track beacon-APIs#580 (unmerged) and are best-effort until verified against a Gloas devnet BN: only the bare block is handled (payload-included BlockContents/blobs deferred) and publish targets the first available client rather than broadcasting. --- beacon/goclient/gloas_proposer.go | 98 ++++++++++++++++++++ beacon/goclient/gloas_proposer_test.go | 89 ++++++++++++++++++ protocol/v2/blockchain/beacon/client.go | 12 +++ protocol/v2/blockchain/beacon/mock_client.go | 82 ++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 beacon/goclient/gloas_proposer.go create mode 100644 beacon/goclient/gloas_proposer_test.go diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go new file mode 100644 index 0000000000..0927e7ff7b --- /dev/null +++ b/beacon/goclient/gloas_proposer.go @@ -0,0 +1,98 @@ +package goclient + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// Gloas produce/publish endpoints (beacon-APIs#580, unmerged). produceBlockV4 reuses the v3 produce +// path with a Gloas SSZ response; publish is the standard v2 blocks endpoint. The exact path/headers +// may still shift upstream — these are best-effort and must be verified against a real Gloas devnet BN. +const ( + gloasProduceBlockPath = "/eth/v3/validator/blocks/%d?randao_reveal=%s&graffiti=%s" // slot, randao 0x-hex, graffiti 0x-hex + gloasPublishBlockPath = "/eth/v2/beacon/blocks" +) + +// GetGloasBeaconBlock produces a Gloas (ePBS) block via the produce endpoint as SSZ — go-eth2-client +// has no Gloas types. Only the bare block is handled; a payload-included BlockContents response +// (blobs/KZG) is deferred. +func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + return firstClientResult(ctx, gc, "GetGloasBeaconBlock", http.MethodGet, func(ctx context.Context, addr string) (*gloas.BeaconBlock, error) { + return requestGloasBeaconBlock(ctx, addr, slot, graffiti, randao) + }) +} + +// SubmitGloasBeaconBlock publishes a signed Gloas (ePBS) block as SSZ. +func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { + body, err := block.MarshalSSZ() + if err != nil { + return fmt.Errorf("marshal signed gloas block: %w", err) + } + _, err = firstClientResult(ctx, gc, "SubmitGloasBeaconBlock", http.MethodPost, func(ctx context.Context, addr string) (struct{}, error) { + return struct{}{}, submitGloasBeaconBlock(ctx, addr, body) + }) + return err +} + +// requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. +func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(graffiti)) + body, err := gloasBlockHTTP(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + block := &gloas.BeaconBlock{} + if err := block.UnmarshalSSZ(body); err != nil { + return nil, fmt.Errorf("decode gloas beacon block: %w", err) + } + return block, nil +} + +// submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. +func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { + _, err := gloasBlockHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ) + return err +} + +// gloasBlockHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns +// the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas +// consensus version. +func gloasBlockHTTP(ctx context.Context, method, url string, body []byte) ([]byte, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Accept", "application/octet-stream") + if body != nil { + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Eth-Consensus-Version", consensusVersionGloas) + } + + resp, err := ptcHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, url, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBody))) + } + return respBody, nil +} diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go new file mode 100644 index 0000000000..49157a664f --- /dev/null +++ b/beacon/goclient/gloas_proposer_test.go @@ -0,0 +1,89 @@ +package goclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + bitfield "github.com/prysmaticlabs/go-bitfield" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// GoClient must satisfy the Gloas proposer beacon-node surface. +var _ beacon.GloasProposerCalls = (*GoClient)(nil) + +func minimalGloasBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: 7, + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{BuilderIndex: gloas.BuilderIndexSelfBuild}}, + ParentExecutionRequests: &electra.ExecutionRequests{}, + }, + } +} + +func TestRequestGloasBeaconBlock(t *testing.T) { + blockSSZ, err := minimalGloasBlock().MarshalSSZ() + require.NoError(t, err) + + var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotRandao = r.URL.Query().Get("randao_reveal") + gotGraffiti = r.URL.Query().Get("graffiti") + gotAccept = r.Header.Get("Accept") + _, _ = w.Write(blockSSZ) + })) + defer srv.Close() + + got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}) + require.NoError(t, err) + require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, "/eth/v3/validator/blocks/7", gotPath) + require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th + require.Equal(t, "0x02", gotGraffiti) + require.Equal(t, "application/octet-stream", gotAccept) + require.Equal(t, phase0.Slot(7), got.Slot) +} + +func TestSubmitGloasBeaconBlock(t *testing.T) { + var gotMethod, gotPath, gotVersion, gotContentType string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotVersion = r.Header.Get("Eth-Consensus-Version") + gotContentType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}) + require.NoError(t, err) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v2/beacon/blocks", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + require.Equal(t, "application/octet-stream", gotContentType) + require.Equal(t, []byte{0x01, 0x02}, gotBody) +} + +func TestGloasBlockHTTP_Non2xxIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad block")) + })) + defer srv.Close() + + _, err := gloasBlockHTTP(context.Background(), http.MethodGet, srv.URL, nil) + require.ErrorContains(t, err, "status 400") +} diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index c4380ba4dc..23d1e106c7 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -129,6 +129,7 @@ type BeaconNode interface { VoluntaryExitCalls PTCCalls ProposerPreferencesCalls + GloasProposerCalls DomainCalls beaconDuties @@ -160,3 +161,14 @@ type ProposerPreferencesCalls interface { // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error } + +// GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks +// (SIP #94 §4). go-eth2-client has no Gloas types, so these are hand-rolled over HTTP against the +// produceBlockV4 / publish endpoints (beacon-APIs#580, unmerged) — verify and iterate on a Gloas devnet. +type GloasProposerCalls interface { + // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships + // separately in the §6 envelope, so the block carries only the execution-payload bid. + GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) + // SubmitGloasBeaconBlock publishes a signed Gloas block. + SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error +} diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index 9f113c81c2..c33c776e0f 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -830,6 +830,21 @@ func (mr *MockBeaconNodeMockRecorder) GetBeaconBlock(ctx, slot, graffiti, randao return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).GetBeaconBlock), ctx, slot, graffiti, randao) } +// GetGloasBeaconBlock mocks base method. +func (m *MockBeaconNode) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) + ret0, _ := ret[0].(*gloas.BeaconBlock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. +func (mr *MockBeaconNodeMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) +} + // GetSyncCommitteeContribution mocks base method. func (m *MockBeaconNode) GetSyncCommitteeContribution(ctx context.Context, slot phase0.Slot, selectionProofs []phase0.BLSSignature, subnetIDs []uint64) (ssz.Marshaler, spec.DataVersion, error) { m.ctrl.T.Helper() @@ -1019,6 +1034,20 @@ func (mr *MockBeaconNodeMockRecorder) SubmitBeaconCommitteeSubscriptions(ctx, su return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBeaconCommitteeSubscriptions", reflect.TypeOf((*MockBeaconNode)(nil).SubmitBeaconCommitteeSubscriptions), ctx, subscription) } +// SubmitGloasBeaconBlock mocks base method. +func (m *MockBeaconNode) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. +func (mr *MockBeaconNodeMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).SubmitGloasBeaconBlock), ctx, block) +} + // SubmitPayloadAttestationMessages mocks base method. func (m *MockBeaconNode) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { m.ctrl.T.Helper() @@ -1308,3 +1337,56 @@ func (mr *MockProposerPreferencesCallsMockRecorder) SubmitProposerPreferences(ct mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposerPreferences", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).SubmitProposerPreferences), ctx, preferences) } + +// MockGloasProposerCalls is a mock of GloasProposerCalls interface. +type MockGloasProposerCalls struct { + ctrl *gomock.Controller + recorder *MockGloasProposerCallsMockRecorder + isgomock struct{} +} + +// MockGloasProposerCallsMockRecorder is the mock recorder for MockGloasProposerCalls. +type MockGloasProposerCallsMockRecorder struct { + mock *MockGloasProposerCalls +} + +// NewMockGloasProposerCalls creates a new mock instance. +func NewMockGloasProposerCalls(ctrl *gomock.Controller) *MockGloasProposerCalls { + mock := &MockGloasProposerCalls{ctrl: ctrl} + mock.recorder = &MockGloasProposerCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGloasProposerCalls) EXPECT() *MockGloasProposerCallsMockRecorder { + return m.recorder +} + +// GetGloasBeaconBlock mocks base method. +func (m *MockGloasProposerCalls) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) + ret0, _ := ret[0].(*gloas.BeaconBlock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. +func (mr *MockGloasProposerCallsMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) +} + +// SubmitGloasBeaconBlock mocks base method. +func (m *MockGloasProposerCalls) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. +func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block) +} From a538cdfa4d926ce7eeb5649970be472795f26b44 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 21:54:19 +0300 Subject: [PATCH 036/150] =?UTF-8?q?gloas:=20run=20the=20proposer=20duty=20?= =?UTF-8?q?on=20node-side=20Gloas=20blocks=20(SIP=20#94=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec ProposerConsensusData.Version is a spec.DataVersion that can't represent Gloas, and its GetBlockData/Validate reject unknown versions, so the Gloas proposer path bypasses the spec block decoder at every site, gated on IsGloas(slot) with networkconfig.DataVersionGloas as a fail-safe value marker (a missed spec call-site errors out rather than mis-decoding): - ProcessPreConsensus produces via GetGloasBeaconBlock and wraps the block SSZ directly — the ePBS block is bid-only (the payload ships in the §6 envelope), so there is no blinding. - ProcessConsensus and expectedPostConsensusRootsAndDomain decode the block node-side; the block doubles as the ssz.HashRoot the proposer signs. - ProcessPostConsensus content-matches the decided value against this operator's cached block: only the operator that built it publishes (it alone can reveal the matching payload in the envelope), while the others complete the duty without submitting. This replaces the leader-ID check, which could miss the holder after a round change. - value_check.go validates the value and reads the slashing slot via the node-side decode. Shared post-submit bookkeeping is factored into finishSubmittedProposal. Adds gloas.DecodeBeaconBlock and a shared gloas.TestingBeaconBlock fixture; covered by value-check and runner content-match tests. --- protocol/v2/ssv/runner/proposer.go | 220 ++++++++++++++++++------ protocol/v2/ssv/runner/proposer_test.go | 115 ++++++++++++- protocol/v2/ssv/value_check.go | 35 +++- protocol/v2/ssv/value_check_test.go | 58 +++++++ protocol/v2/types/gloas/beacon_block.go | 11 ++ protocol/v2/types/gloas/testing.go | 22 +++ 6 files changed, 393 insertions(+), 68 deletions(-) create mode 100644 protocol/v2/types/gloas/testing.go diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index de47971677..7c9ddb8cb3 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -21,6 +21,7 @@ import ( "github.com/ssvlabs/ssv/ssvsigner/ekm" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/observability" "github.com/ssvlabs/ssv/observability/log/fields" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" @@ -29,6 +30,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" "github.com/ssvlabs/ssv/protocol/v2/ssv" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type ProposerRunner struct { @@ -58,6 +60,11 @@ type ProposerRunner struct { // cachedBlindedBlockSSZ is a fingerprint of the cachedFullBlock, it is stored here // for efficient validation (so we re-use it instead of re-calculating). cachedBlindedBlockSSZ []byte + + // cachedGloasBlockSSZ holds the SSZ of the Gloas (ePBS) block this operator fetched for the duty. + // Post-consensus content-matches it against the decided value to detect whether this operator + // built the decided block — only that operator publishes it (and can later reveal its payload). + cachedGloasBlockSSZ []byte } // ProposerRunnerOptions bundles all dependencies required by NewProposerRunner. @@ -176,63 +183,106 @@ func (r *ProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Lo // Fetch the block our operator will propose if it is a Leader (note, even if our operator // isn't leading the 1st QBFT round it might become a Leader in case of round change - hence // we are always fetching Ethereum block here just in case we need to propose it). - start := time.Now() - vBlk, _, err := r.GetBeaconNode().GetBeaconBlock(ctx, duty.Slot, r.graffiti, fullSig) - if err != nil { - return fmt.Errorf("get beacon block: %w", err) + var input *spectypes.ProposerConsensusData + if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(duty.Slot)) { + input, err = r.gloasProposalInput(ctx, logger, duty, fullSig) + if err != nil { + return err + } + } else { + start := time.Now() + vBlk, _, err := r.GetBeaconNode().GetBeaconBlock(ctx, duty.Slot, r.graffiti, fullSig) + if err != nil { + return fmt.Errorf("get beacon block: %w", err) + } + // Log essentials about the retrieved block. + logFields, proposalTraceAttrs := proposalCommonFields(vBlk) + logFields = append( + logFields, + zap.Duration("proposer_delay", r.proposerDelay), + fields.Took(time.Since(start)), + ) + + feeRecipient, err := vBlk.FeeRecipient() + if err != nil { + logFields = append(logFields, zap.NamedError("feeRecipient_err", err)) + } else { + logFields = append(logFields, fields.FeeRecipient(feeRecipient[:])) + } + const eventMsg = "🧊 got beacon block proposal" + logger.Info(eventMsg, logFields...) + span.AddEvent(eventMsg, trace.WithAttributes(proposalTraceAttrs...)) + + // Ensure we propose a blinded block in QBFT. If the beacon returned a full + // block, convert it to blinded form by swapping the execution payload with + // its header (+ cache the original block so we can submit it later). + // Consensus value carries the blinded block SSZ. + blindedVBlk, blindedMarshaler, err := blindutil.EnsureBlinded(vBlk) + if err != nil { + return fmt.Errorf("failed to blind full block: %w", err) + } + + byts, err := blindedMarshaler.MarshalSSZ() + if err != nil { + return fmt.Errorf("could not marshal blinded beacon block: %w", err) + } + + // Store the original block (we are only interested in full blocks) for later re-use + // in the post-consensus phase. + if !vBlk.Blinded { + r.cachedFullBlock = vBlk + r.cachedBlindedBlockSSZ = byts + } + + input = &spectypes.ProposerConsensusData{ + Duty: *duty, + Version: blindedVBlk.Version, + DataSSZ: byts, + } } - // Log essentials about the retrieved block. - logFields, proposalTraceAttrs := proposalCommonFields(vBlk) - logFields = append( - logFields, - zap.Duration("proposer_delay", r.proposerDelay), - fields.Took(time.Since(start)), - ) - feeRecipient, err := vBlk.FeeRecipient() - if err != nil { - logFields = append(logFields, zap.NamedError("feeRecipient_err", err)) - } else { - logFields = append(logFields, fields.FeeRecipient(feeRecipient[:])) + r.measurements.StartConsensus() + if err := r.decide(ctx, logger, duty.Slot, input, r.ValCheck); err != nil { + return fmt.Errorf("qbft-decide: %w", err) } - const eventMsg = "🧊 got beacon block proposal" - logger.Info(eventMsg, logFields...) - span.AddEvent(eventMsg, trace.WithAttributes(proposalTraceAttrs...)) - // Ensure we propose a blinded block in QBFT. If the beacon returned a full - // block, convert it to blinded form by swapping the execution payload with - // its header (+ cache the original block so we can submit it later). - // Consensus value carries the blinded block SSZ. + return nil +} - blindedVBlk, blindedMarshaler, err := blindutil.EnsureBlinded(vBlk) +// gloasProposalInput fetches the Gloas (ePBS) block this operator would propose and wraps it as the +// QBFT consensus value. The block carries only the execution-payload bid (the payload ships in the §6 +// envelope), so unlike the pre-Gloas path there is no blinding; the marshaled block is cached so +// post-consensus can detect whether this operator built the decided block. +func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Logger, duty *spectypes.ValidatorDuty, randaoReveal []byte) (*spectypes.ProposerConsensusData, error) { + start := time.Now() + block, err := r.GetBeaconNode().GetGloasBeaconBlock(ctx, duty.Slot, r.graffiti, randaoReveal) if err != nil { - return fmt.Errorf("failed to blind full block: %w", err) + return nil, fmt.Errorf("get gloas beacon block: %w", err) } - byts, err := blindedMarshaler.MarshalSSZ() + byts, err := block.MarshalSSZ() if err != nil { - return fmt.Errorf("could not marshal blinded beacon block: %w", err) + return nil, fmt.Errorf("could not marshal gloas beacon block: %w", err) } + r.cachedGloasBlockSSZ = byts - // Store the original block (we are only interested in full blocks) for later re-use - // in the post-consensus phase. - if !vBlk.Blinded { - r.cachedFullBlock = vBlk - r.cachedBlindedBlockSSZ = byts + logFields := []zap.Field{ + fields.Slot(duty.Slot), + zap.Duration("proposer_delay", r.proposerDelay), + fields.Took(time.Since(start)), + } + if bid := block.Body.SignedExecutionPayloadBid; bid != nil && bid.Message != nil { + logFields = append(logFields, fields.FeeRecipient(bid.Message.FeeRecipient[:])) } + const eventMsg = "🧊 got gloas beacon block proposal" + logger.Info(eventMsg, logFields...) + trace.SpanFromContext(ctx).AddEvent(eventMsg) - input := &spectypes.ProposerConsensusData{ + return &spectypes.ProposerConsensusData{ Duty: *duty, - Version: blindedVBlk.Version, + Version: networkconfig.DataVersionGloas, DataSSZ: byts, - } - - r.measurements.StartConsensus() - if err := r.decide(ctx, logger, duty.Slot, input, r.ValCheck); err != nil { - return fmt.Errorf("qbft-decide: %w", err) - } - - return nil + }, nil } func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { @@ -259,15 +309,27 @@ func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logge observability.ValidatorPublicKeyAttribute(cd.Duty.PubKey), ) - versionedBlock, blkRootToSign, err := cd.GetBlockData() - if err != nil { - return fmt.Errorf("could not get block data from consensus data: %w", err) - } - - if versionedBlock.Blinded { - span.AddEvent("decided has a blinded block") + var blkRootToSign ssz.HashRoot + if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + // Gloas blocks have no spectypes block version; decode the node-side block, which doubles as + // the ssz.HashRoot to sign. + block, decErr := gloas.DecodeBeaconBlock(cd.DataSSZ) + if decErr != nil { + return fmt.Errorf("could not decode gloas block from consensus data: %w", decErr) + } + blkRootToSign = block + span.AddEvent("decided has a gloas block") } else { - span.AddEvent("decided has a vanilla block") + versionedBlock, signingRoot, err := cd.GetBlockData() + if err != nil { + return fmt.Errorf("could not get block data from consensus data: %w", err) + } + blkRootToSign = signingRoot + if versionedBlock.Blinded { + span.AddEvent("decided has a blinded block") + } else { + span.AddEvent("decided has a vanilla block") + } } duty, err := r.currentValidatorDuty() @@ -394,6 +456,11 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L if err != nil { return fmt.Errorf("could not decode decided validator consensus data: %w", err) } + + if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(validatorConsensusData.Duty.Slot)) { + return r.submitGloasProposal(ctx, logger, span, validatorConsensusData, specSig) + } + vBlk, _, err := validatorConsensusData.GetBlockData() if err != nil { return fmt.Errorf("could not get block data from consensus data: %w", err) @@ -413,7 +480,6 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L } loggerFields, proposalTraceAttrs := proposalCommonFields(vBlk) - logger = logger.With(loggerFields...) start := time.Now() @@ -421,6 +487,13 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L recordFailedSubmission(ctx, spectypes.BNRoleProposer) return fmt.Errorf("submit beacon block: %w", err) } + return r.finishSubmittedProposal(ctx, logger, span, start, proposalTraceAttrs) +} + +// finishSubmittedProposal records metrics, marks the duty succeeded, and logs after a proposal block +// has been submitted to the beacon node. submittedAt is when the submission started (for the Took +// metric); proposalTraceAttrs are block-specific span attributes (nil for Gloas). +func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *zap.Logger, span trace.Span, submittedAt time.Time, proposalTraceAttrs []attribute.KeyValue) error { currentDutySlot, err := r.currentDutySlot() if err != nil { return fmt.Errorf("current duty slot: %w", err) @@ -432,7 +505,7 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L observability.DutyRoundAttribute(r.State.RunningInstance.State.Round), }, proposalTraceAttrs...) span.AddEvent(submittedBlockProposalEvent, trace.WithAttributes(submittedAttrs...)) - logger.Info(submittedBlockProposalEvent, fields.Took(time.Since(start))) + logger.Info(submittedBlockProposalEvent, fields.Took(time.Since(submittedAt))) r.markDutySucceeded() r.measurements.EndDutyFlow() @@ -451,6 +524,32 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L return nil } +// submitGloasProposal publishes the decided Gloas (ePBS) block, but only from the operator that built +// it: the decided value is content-matched against this operator's cached block. Only that operator can +// later reveal the matching payload in the §6 envelope, so the others complete the duty without +// submitting (the builder publishes). +func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { + block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) + if err != nil { + return fmt.Errorf("could not decode decided gloas block: %w", err) + } + + if !bytes.Equal(r.cachedGloasBlockSSZ, cd.DataSSZ) { + logger.Debug("this operator did not build the decided gloas block, skipping submission") + r.markDutySucceeded() + r.measurements.EndDutyFlow() + return nil + } + + start := time.Now() + signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} + if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { + recordFailedSubmission(ctx, spectypes.BNRoleProposer) + return fmt.Errorf("submit gloas beacon block: %w", err) + } + return r.finishSubmittedProposal(ctx, logger, span, start, nil) +} + func (r *ProposerRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { currentDutySlot, err := r.currentDutySlot() if err != nil { @@ -468,9 +567,19 @@ func (r *ProposerRunner) expectedPostConsensusRootsAndDomain(context.Context) ([ return nil, phase0.DomainType{}, fmt.Errorf("could not decode consensus data: %w", err) } - _, signedRoot, err := validatorConsensusData.GetBlockData() - if err != nil { - return nil, phase0.DomainType{}, fmt.Errorf("could not get block data: %w", err) + var signedRoot ssz.HashRoot + if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(validatorConsensusData.Duty.Slot)) { + block, decErr := gloas.DecodeBeaconBlock(validatorConsensusData.DataSSZ) + if decErr != nil { + return nil, phase0.DomainType{}, fmt.Errorf("could not decode gloas block: %w", decErr) + } + signedRoot = block + } else { + _, root, bdErr := validatorConsensusData.GetBlockData() + if bdErr != nil { + return nil, phase0.DomainType{}, fmt.Errorf("could not get block data: %w", bdErr) + } + signedRoot = root } return []ssz.HashRoot{signedRoot}, spectypes.DomainProposer, nil } @@ -499,6 +608,7 @@ func (r *ProposerRunner) executeDuty(ctx context.Context, logger *zap.Logger, du // reset the cached original block at the beginning of a new duty r.cachedFullBlock = nil r.cachedBlindedBlockSSZ = nil + r.cachedGloasBlockSSZ = nil // sign partial randao span.AddEvent("signing beacon object") diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 5546d88c6c..52b03a32ce 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -14,6 +14,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" spectestingutils "github.com/ssvlabs/ssv-spec/types/testingutils" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" @@ -23,6 +24,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/qbft/roundtimer" "github.com/ssvlabs/ssv/protocol/v2/ssv" protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -38,6 +40,9 @@ type proposerTestBeacon struct { submittedBlocks []*api.VersionedProposal submittedSig []phase0.BLSSignature submitErr error + + getGloasBlock *gloas.BeaconBlock + submittedGloasBlocks []*gloas.SignedBeaconBlock } func newProposerTestBeacon(proposal *api.VersionedProposal) *proposerTestBeacon { @@ -61,6 +66,19 @@ func (b *proposerTestBeacon) SubmitBeaconBlock(_ context.Context, block *api.Ver return b.submitErr } +func (b *proposerTestBeacon) GetGloasBeaconBlock(_ context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + b.getCalls++ + b.lastGetSlot = slot + b.lastGetGraffiti = append([]byte(nil), graffiti...) + b.lastGetRandao = append([]byte(nil), randao...) + return b.getGloasBlock, nil +} + +func (b *proposerTestBeacon) SubmitGloasBeaconBlock(_ context.Context, block *gloas.SignedBeaconBlock) error { + b.submittedGloasBlocks = append(b.submittedGloasBlocks, block) + return b.submitErr +} + type stubDoppelganger struct { canSign bool reportQuorum []phase0.ValidatorIndex @@ -297,7 +315,7 @@ func TestProposerRunnerProcessPostConsensusLeaderUsesCachedFullBlockWhenDecision dg := &stubDoppelganger{canSign: true} runner, keySet, _ := newProposerRunnerForTest(t, beacon, dg, 0, nil) - setupRunnerForPostConsensus(t, runner, keySet, consensusData, 1) + setupRunnerForPostConsensus(t, runner, keySet, spectestingutils.TestingProposerDutyV(version), consensusData, 1) runner.cachedFullBlock = fullBlock runner.cachedBlindedBlockSSZ = append([]byte(nil), consensusData.DataSSZ...) @@ -320,7 +338,7 @@ func TestProposerRunnerProcessPostConsensusLeaderFallsBackToDecidedBlindedBlockO dg := &stubDoppelganger{canSign: true} runner, keySet, _ := newProposerRunnerForTest(t, beacon, dg, 0, nil) - setupRunnerForPostConsensus(t, runner, keySet, consensusData, 1) + setupRunnerForPostConsensus(t, runner, keySet, spectestingutils.TestingProposerDutyV(version), consensusData, 1) runner.cachedFullBlock = spectestingutils.TestingBeaconBlockV(version) runner.cachedBlindedBlockSSZ = []byte("different-blinded-block") @@ -341,7 +359,7 @@ func TestProposerRunnerProcessPostConsensusNonLeaderKeepsDecidedBlindedBlock(t * dg := &stubDoppelganger{canSign: true} runner, keySet, _ := newProposerRunnerForTest(t, beacon, dg, 0, nil) - setupRunnerForPostConsensus(t, runner, keySet, consensusData, 1) + setupRunnerForPostConsensus(t, runner, keySet, spectestingutils.TestingProposerDutyV(version), consensusData, 1) runner.operatorSigner = fixedOperatorSigner{id: 2} runner.cachedFullBlock = spectestingutils.TestingBeaconBlockV(version) runner.cachedBlindedBlockSSZ = append([]byte(nil), consensusData.DataSSZ...) @@ -354,6 +372,95 @@ func TestProposerRunnerProcessPostConsensusNonLeaderKeepsDecidedBlindedBlock(t * require.True(t, runner.State.Succeeded) } +func gloasProposerDuty(slot phase0.Slot) *spectypes.ValidatorDuty { + return &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposer, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: slot, + ValidatorIndex: spectestingutils.TestingValidatorIndex, + CommitteeIndex: 3, + CommitteesAtSlot: 36, + CommitteeLength: 128, + ValidatorCommitteeIndex: 11, + } +} + +func gloasProposerConsensusData(t *testing.T, slot phase0.Slot) *spectypes.ProposerConsensusData { + t.Helper() + dataSSZ, err := gloas.TestingBeaconBlock(slot).MarshalSSZ() + require.NoError(t, err) + return &spectypes.ProposerConsensusData{ + Duty: *gloasProposerDuty(slot), + Version: networkconfig.DataVersionGloas, + DataSSZ: dataSSZ, + } +} + +// The operator that built the decided Gloas block (its cached block content-matches the decided value) +// signs and publishes it. +func TestProposerRunnerSubmitGloasProposalBuilderPublishes(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + consensusData := gloasProposerConsensusData(t, slot) + beacon := newProposerTestBeacon(nil) + runner, keySet, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) + + setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) + runner.cachedGloasBlockSSZ = append([]byte(nil), consensusData.DataSSZ...) + + err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{0xab}) + require.NoError(t, err) + + require.Len(t, beacon.submittedGloasBlocks, 1) + require.Equal(t, slot, beacon.submittedGloasBlocks[0].Message.Slot) + require.Equal(t, phase0.BLSSignature{0xab}, beacon.submittedGloasBlocks[0].Signature) + require.True(t, runner.State.Succeeded) +} + +// An operator that did not build the decided block (content mismatch) completes the duty without +// submitting — only the builder can later reveal the matching payload. +func TestProposerRunnerSubmitGloasProposalNonBuilderSkips(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + consensusData := gloasProposerConsensusData(t, slot) + beacon := newProposerTestBeacon(nil) + runner, keySet, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) + + setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) + runner.cachedGloasBlockSSZ = []byte("a-different-block") + + err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{0xab}) + require.NoError(t, err) + + require.Empty(t, beacon.submittedGloasBlocks) + require.True(t, runner.State.Succeeded) +} + +// gloasProposalInput fetches the Gloas block from the beacon node, wraps it as the consensus value +// with the Gloas version marker, and caches the SSZ for the post-consensus content-match. +func TestProposerRunnerGloasProposalInput(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + beacon := newProposerTestBeacon(nil) + beacon.getGloasBlock = gloas.TestingBeaconBlock(slot) + runner, _, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) + + input, err := runner.gloasProposalInput(context.Background(), zap.NewNop(), gloasProposerDuty(slot), []byte("randao")) + require.NoError(t, err) + + expectedSSZ, err := gloas.TestingBeaconBlock(slot).MarshalSSZ() + require.NoError(t, err) + require.Equal(t, networkconfig.DataVersionGloas, input.Version) + require.Equal(t, expectedSSZ, input.DataSSZ) + require.Equal(t, expectedSSZ, runner.cachedGloasBlockSSZ) + require.Equal(t, slot, beacon.lastGetSlot) + require.Equal(t, []byte("graffiti"), beacon.lastGetGraffiti) + require.Equal(t, []byte("randao"), beacon.lastGetRandao) +} + func newProposerRunnerForTest( t *testing.T, beacon *proposerTestBeacon, @@ -429,12 +536,12 @@ func setupRunnerForPostConsensus( t *testing.T, runner *ProposerRunner, keySet *spectestingutils.TestKeySet, + duty *spectypes.ValidatorDuty, consensusData *spectypes.ProposerConsensusData, leaderID spectypes.OperatorID, ) { t.Helper() - duty := spectestingutils.TestingProposerDutyV(consensusData.Version) runner.State = NewRunnerState(keySet.Threshold, duty) runner.measurements.StartDutyFlow() runner.measurements.StartConsensus() diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index 247f12c505..ef9e2d6c5a 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -247,14 +247,24 @@ func (v *proposerChecker) CheckValue(value []byte) error { return err } - blockData, _, err := cd.GetBlockData() - if err != nil { - return fmt.Errorf("could not get block data: %w", err) - } - - slot, err := blockData.Slot() - if err != nil { - return fmt.Errorf("failed to get slot from block data: %w", err) + var slot phase0.Slot + if v.beaconConfig.IsGloas(v.beaconConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + // Gloas blocks have no spectypes block version; GetBlockData can't decode them, so read the + // slot from the node-side block directly. + block, decErr := gloas.DecodeBeaconBlock(cd.DataSSZ) + if decErr != nil { + return fmt.Errorf("could not decode gloas block: %w", decErr) + } + slot = block.Slot + } else { + blockData, _, bdErr := cd.GetBlockData() + if bdErr != nil { + return fmt.Errorf("could not get block data: %w", bdErr) + } + slot, bdErr = blockData.Slot() + if bdErr != nil { + return fmt.Errorf("failed to get slot from block data: %w", bdErr) + } } return v.signer.IsBeaconBlockSlashable(v.sharePublicKey, slot) } @@ -316,7 +326,14 @@ func checkValidatorConsensusData( if err := cd.Decode(value); err != nil { return nil, fmt.Errorf("failed decoding consensus data: %w", err) } - if err := ssvtypes.ValidateConsensusData(cd); err != nil { + + if cd.Duty.Type == spectypes.BNRoleProposer && beaconConfig.IsGloas(beaconConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + // Gloas blocks have no spectypes block version, so ValidateConsensusData's GetBlockData path + // can't decode them; a successful node-side decode is the validity check. + if _, err := gloas.DecodeBeaconBlock(cd.DataSSZ); err != nil { + return cd, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") + } + } else if err := ssvtypes.ValidateConsensusData(cd); err != nil { return cd, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") } diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 05a745f959..4ba68017b3 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -8,6 +8,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -123,6 +124,10 @@ func (f fakeSlashingSigner) IsAttestationSlashable(phase0.BLSPubKey, *phase0.Att return f.slashable } +func (f fakeSlashingSigner) IsBeaconBlockSlashable(phase0.BLSPubKey, phase0.Slot) error { + return f.slashable +} + func gloasVote(source, target phase0.Epoch, index phase0.CommitteeIndex) *gloas.GloasBeaconVote { return &gloas.GloasBeaconVote{ BlockRoot: phase0.Root{0x01}, @@ -185,3 +190,56 @@ func TestGloasVoteChecker_DecodeError(t *testing.T) { checker := newGloasChecker(fakeSlashingSigner{}, expected) require.Error(t, checker.CheckValue([]byte{0x00, 0x01, 0x02})) // too short for a 120-byte vote } + +// --- proposer checker, Gloas (ePBS) --- + +const gloasProposerSlot = phase0.Slot(8) + +var gloasProposerPK = phase0.BLSPubKey{0x42} + +func gloasProposerConsensusData(t *testing.T, dataSSZ []byte) []byte { + t.Helper() + cd := &spectypes.ProposerConsensusData{ + Duty: spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposer, + PubKey: gloasProposerPK, + ValidatorIndex: 7, + Slot: gloasProposerSlot, + }, + Version: networkconfig.DataVersionGloas, + DataSSZ: dataSSZ, + } + out, err := cd.Encode() + require.NoError(t, err) + return out +} + +func gloasBlockSSZ(t *testing.T, slot phase0.Slot) []byte { + t.Helper() + dataSSZ, err := gloas.TestingBeaconBlock(slot).MarshalSSZ() + require.NoError(t, err) + return dataSSZ +} + +func newGloasProposerChecker(signer ekm.BeaconSigner) ValueChecker { + cfg := networkconfig.TestNetworkWithGloas(0) + return NewProposerChecker(signer, cfg.Beacon, spectypes.ValidatorPK(gloasProposerPK), 7, phase0.BLSPubKey{}) +} + +// A Gloas proposer value validates via the node-side block decode (there is no spectypes Gloas block +// version); the decoded block's slot drives the slashing check. +func TestProposerChecker_GloasValid(t *testing.T) { + checker := newGloasProposerChecker(fakeSlashingSigner{}) + require.NoError(t, checker.CheckValue(gloasProposerConsensusData(t, gloasBlockSSZ(t, gloasProposerSlot)))) +} + +func TestProposerChecker_GloasSlashable(t *testing.T) { + checker := newGloasProposerChecker(fakeSlashingSigner{slashable: fmt.Errorf("slashable")}) + require.Error(t, checker.CheckValue(gloasProposerConsensusData(t, gloasBlockSSZ(t, gloasProposerSlot)))) +} + +// DataSSZ that is not a valid Gloas block fails the node-side validity check. +func TestProposerChecker_GloasDecodeError(t *testing.T) { + checker := newGloasProposerChecker(fakeSlashingSigner{}) + require.Error(t, checker.CheckValue(gloasProposerConsensusData(t, []byte{0x00, 0x01, 0x02}))) +} diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go index 5982bc87f0..ee6b456f92 100644 --- a/protocol/v2/types/gloas/beacon_block.go +++ b/protocol/v2/types/gloas/beacon_block.go @@ -64,3 +64,14 @@ func (b *BeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } func (b *SignedBeaconBlock) Encode() ([]byte, error) { return b.MarshalSSZ() } func (b *SignedBeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } + +// DecodeBeaconBlock unmarshals a Gloas BeaconBlock from QBFT consensus DataSSZ. It is the proposer +// path's node-side replacement for spectypes.ProposerConsensusData.GetBlockData, which has no Gloas +// version; the returned block doubles as the ssz.HashRoot the proposer signs. +func DecodeBeaconBlock(dataSSZ []byte) (*BeaconBlock, error) { + b := &BeaconBlock{} + if err := b.UnmarshalSSZ(dataSSZ); err != nil { + return nil, err + } + return b, nil +} diff --git a/protocol/v2/types/gloas/testing.go b/protocol/v2/types/gloas/testing.go new file mode 100644 index 0000000000..b29e685961 --- /dev/null +++ b/protocol/v2/types/gloas/testing.go @@ -0,0 +1,22 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + bitfield "github.com/prysmaticlabs/go-bitfield" +) + +// TestingBeaconBlock returns a minimal self-build Gloas BeaconBlock for the slot, with the required +// fixed-size body fields populated so it round-trips through SSZ. For use in tests. +func TestingBeaconBlock(slot phase0.Slot) *BeaconBlock { + return &BeaconBlock{ + Slot: slot, + Body: &BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &SignedExecutionPayloadBid{Message: &ExecutionPayloadBid{BuilderIndex: BuilderIndexSelfBuild}}, + ParentExecutionRequests: &electra.ExecutionRequests{}, + }, + } +} From 2c99bc49ee3c9cb52ab43e387f03e99117b636f0 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 22:00:12 +0300 Subject: [PATCH 037/150] gloas: tidy stale comments and a parse-error message from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GloasBeaconVote's "not yet wired" note is stale — the committee runner now agrees on it for Gloas slots (its doc paragraph already says so), so drop the contradictory note. - The committee post-consensus parse error said "BeaconVote" even on the Gloas path, where the value is a GloasBeaconVote; make it fork-neutral. - Document that ProposerDutiesDependentRoot's singleflight collapses onto the winning caller's ctx (its cancellation fails the waiters) — benign given the shared deadline window and re-emit recovery. --- beacon/goclient/proposer_preferences.go | 4 +++- protocol/v2/ssv/runner/runner_validations.go | 2 +- protocol/v2/types/gloas/beacon_vote.go | 3 --- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index f4f9b35a48..bc38aad1ea 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -20,7 +20,9 @@ import ( func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { // Several proposer-preferences runners (one per local proposing validator in the epoch) request the // same epoch's dependent_root concurrently; collapse that burst into a single GET. Not TTL-cached so - // a reorg re-emission still observes a fresh root. + // a reorg re-emission still observes a fresh root. The collapsed call adopts the winning caller's + // ctx, so its cancellation also fails the concurrent waiters — acceptable as they share the slot's + // deadline window and a re-emit recovers. root, err, _ := gc.proposerDutiesDependentRootInflight.Do(epoch, func() (phase0.Root, error) { return firstClientResult(ctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { var resp struct { diff --git a/protocol/v2/ssv/runner/runner_validations.go b/protocol/v2/ssv/runner/runner_validations.go index bbbac8b1c8..9f484bffb2 100644 --- a/protocol/v2/ssv/runner/runner_validations.go +++ b/protocol/v2/ssv/runner/runner_validations.go @@ -149,7 +149,7 @@ func (b *BaseRunner) ValidatePostConsensusMsg(ctx context.Context, runner Runner decidedValue = &gloas.GloasBeaconVote{} } if err := decidedValue.Decode(decidedValueBytes); err != nil { - return fmt.Errorf("failed to parse decided value to BeaconVote: %w", err) + return fmt.Errorf("failed to parse decided beacon vote: %w", err) } return b.validatePartialSigMsg(psigMsgs, expectedSlot) diff --git a/protocol/v2/types/gloas/beacon_vote.go b/protocol/v2/types/gloas/beacon_vote.go index 1b24fefc26..8ef11af848 100644 --- a/protocol/v2/types/gloas/beacon_vote.go +++ b/protocol/v2/types/gloas/beacon_vote.go @@ -13,9 +13,6 @@ import ( // Source/Target checkpoints, 112 bytes) and appends the BN-supplied // AttestationData.Index for a fixed 120-byte SSZ encoding (field order per SIP #94). // The 120B-vs-112B length difference makes a cross-fork decode fail cleanly. -// -// Not yet wired: this is the foundation for the Gloas committee runner (the §2 attestation track); -// the PTC slice doesn't use it. Tracked so it isn't mistaken for dead code. type GloasBeaconVote struct { BlockRoot phase0.Root `ssz-size:"32"` Source *phase0.Checkpoint From ec7d776910b408a0b3fbc301a840afbc438c5f35 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 22:00:13 +0300 Subject: [PATCH 038/150] runner: guard GetStateRoot against a nil State GetStateRoot is the one embedded BaseRunner method that neither overrides nor nil-checks State, so it would panic if called on a runner whose State the dispatcher never initialized (currently only reachable from spectest paths). Return an error instead, and add a regression test. --- protocol/v2/ssv/runner/runner.go | 3 +++ protocol/v2/ssv/runner/runner_delegator_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index fb82aedd0b..85fc2fc9e2 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -187,6 +187,9 @@ func (b *BaseRunner) GetLastRound() specqbft.Round { } func (b *BaseRunner) GetStateRoot() ([32]byte, error) { + if b.State == nil { + return [32]byte{}, errors.New("runner state is not initialized") + } return b.State.GetRoot() } diff --git a/protocol/v2/ssv/runner/runner_delegator_test.go b/protocol/v2/ssv/runner/runner_delegator_test.go index 6d53c697ef..182bbf2b22 100644 --- a/protocol/v2/ssv/runner/runner_delegator_test.go +++ b/protocol/v2/ssv/runner/runner_delegator_test.go @@ -9,6 +9,12 @@ import ( "github.com/stretchr/testify/require" ) +// GetStateRoot guards against a nil State (e.g. the duty dispatcher never sets one) instead of panicking. +func TestBaseRunnerGetStateRootNilStateReturnsError(t *testing.T) { + _, err := (&BaseRunner{}).GetStateRoot() + require.Error(t, err) +} + func TestVoluntaryExitRunnerDecodePreservesEmbeddedBaseRunnerMethods(t *testing.T) { t.Parallel() From 951104f36f7d4023ae4c7436ca422e270f8e165f Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 23:24:02 +0300 Subject: [PATCH 039/150] goclient: drop stale 2/3-of-slot wording from the aggregate/contribution waits The aggregate and sync-contribution waits retimed to half the slot at the Gloas fork (waitTwoIntervalsIntoSlot), but the wrapped error strings, the test assertion, and an inline comment still said "2/3 of slot". Make them fork-neutral. --- beacon/goclient/aggregator.go | 6 +++--- beacon/goclient/aggregator_test.go | 2 +- beacon/goclient/sync_committee_contribution.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index 178ec4d134..b311b0e9ba 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -49,11 +49,11 @@ func (gc *GoClient) SubmitAggregateSelectionProof( index phase0.ValidatorIndex, slotSig []byte, ) (ssz.Marshaler, spec.DataVersion, error) { - // As specified in spec, an aggregator should wait until two thirds of the way through slot - // to broadcast the best aggregate to the global aggregate channel. + // As specified in spec, an aggregator waits until the aggregation deadline (see + // waitIntoSlot) to broadcast the best aggregate to the global aggregate channel. // https://github.com/ethereum/consensus-specs/blob/v0.9.3/specs/validator/0_beacon-chain-validator.md#broadcast-aggregate if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { - return nil, 0, fmt.Errorf("wait for 2/3 of slot: %w", err) + return nil, 0, fmt.Errorf("wait for aggregation deadline: %w", err) } va, _, err := gc.fetchVersionedAggregate(ctx, slot, committeeIndex) diff --git a/beacon/goclient/aggregator_test.go b/beacon/goclient/aggregator_test.go index f9b0f0ca1b..b95e705e08 100644 --- a/beacon/goclient/aggregator_test.go +++ b/beacon/goclient/aggregator_test.go @@ -288,7 +288,7 @@ func TestSubmitAggregateSelectionProof_RespectsContextCancellationWhileWaiting(t err := <-errCh require.ErrorIs(t, err, context.Canceled) - require.ErrorContains(t, err, "wait for 2/3 of slot") + require.ErrorContains(t, err, "wait for aggregation deadline") require.Zero(t, attestationCalls.Load()) require.Zero(t, aggregateCalls.Load()) }) diff --git a/beacon/goclient/sync_committee_contribution.go b/beacon/goclient/sync_committee_contribution.go index 7da45ba782..40d75b91eb 100644 --- a/beacon/goclient/sync_committee_contribution.go +++ b/beacon/goclient/sync_committee_contribution.go @@ -76,7 +76,7 @@ func (gc *GoClient) GetSyncCommitteeContribution( blockRoot := beaconBlockRootResp.Data if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { - return nil, DataVersionNil, fmt.Errorf("wait for 2/3 of slot: %w", err) + return nil, DataVersionNil, fmt.Errorf("wait for contribution deadline: %w", err) } // Fetch sync committee contributions for each subnet in parallel. From 9ad18d7155f2a41339bd9a976d77bc6b3660b774 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 23:24:04 +0300 Subject: [PATCH 040/150] gloas: content-match before decoding on the proposer submit path submitGloasProposal decoded the decided block before the bytes.Equal content match, so every non-builder operator decoded a block it immediately discarded. Check the match first and decode only when this operator actually submits; the value-check already validated the block at consensus time. --- protocol/v2/ssv/runner/proposer.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 7c9ddb8cb3..1aa1db5c4c 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -529,11 +529,6 @@ func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *za // later reveal the matching payload in the §6 envelope, so the others complete the duty without // submitting (the builder publishes). func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { - block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) - if err != nil { - return fmt.Errorf("could not decode decided gloas block: %w", err) - } - if !bytes.Equal(r.cachedGloasBlockSSZ, cd.DataSSZ) { logger.Debug("this operator did not build the decided gloas block, skipping submission") r.markDutySucceeded() @@ -541,6 +536,11 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo return nil } + block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) + if err != nil { + return fmt.Errorf("could not decode decided gloas block: %w", err) + } + start := time.Now() signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { From 6bbc8e46730e02cd99623efe939159be5f58b94a Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 23:29:12 +0300 Subject: [PATCH 041/150] networkconfig: add IsGloasAtSlot and use it across slot-keyed callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsGloas(EstimatedEpochAtSlot(slot)) appeared 13x across the runners, validators, duty tracer, and message validation — repetitive and easy to typo (passing a slot where an epoch is wanted, or vice versa). Add a slot-keyed IsGloasAtSlot shorthand on Beacon and route every slot-based caller through it. No behavior change. --- exporter/dutytracer/collector.go | 2 +- message/validation/signed_ssv_message.go | 2 +- networkconfig/beacon.go | 8 +++++++- operator/duties/validator_registration.go | 2 +- protocol/v2/ssv/runner/committee.go | 4 ++-- protocol/v2/ssv/runner/proposer.go | 8 ++++---- protocol/v2/ssv/runner/runner_validations.go | 2 +- protocol/v2/ssv/value_check.go | 4 ++-- 8 files changed, 19 insertions(+), 13 deletions(-) diff --git a/exporter/dutytracer/collector.go b/exporter/dutytracer/collector.go index 539bf8bf24..33929da4d4 100644 --- a/exporter/dutytracer/collector.go +++ b/exporter/dutytracer/collector.go @@ -506,7 +506,7 @@ func (c *Collector) processPartialSigCommittee( // carried attestation index (nil before Gloas) — the duty tracer's mirror of the runner's fork-aware // decode, so the signing roots it derives match what operators actually signed. func (c *Collector) decodeCommitteeVote(slot phase0.Slot, in []byte) (*spectypes.BeaconVote, *phase0.CommitteeIndex, error) { - if c.beacon.IsGloas(c.beacon.EstimatedEpochAtSlot(slot)) { + if c.beacon.IsGloasAtSlot(slot) { gv := &gloas.GloasBeaconVote{} if err := gv.Decode(in); err != nil { return nil, nil, fmt.Errorf("decode gloas beacon vote: %w", err) diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index ef510f15ef..558f91a72d 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -153,7 +153,7 @@ func (mv *messageValidator) validRoleUnion(roleType spectypes.RunnerRole) bool { func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot phase0.Slot) bool { isInBooleFork := mv.netCfg.BooleForkAtSlot(slot) - isInGloas := mv.netCfg.IsGloas(mv.netCfg.EstimatedEpochAtSlot(slot)) + isInGloas := mv.netCfg.IsGloasAtSlot(slot) switch roleType { case spectypes.RoleCommittee, spectypes.RoleProposer, spectypes.RoleVoluntaryExit: return true diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index 8e60e90d9c..e6e26d2c0e 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -144,7 +144,7 @@ func (b *Beacon) TimeAtSlot(slot phase0.Slot) time.Time { // aggregate/contribution 2× (50%), payload attestation 3× (75%); SIP #94 §1. func (b *Beacon) IntervalDuration(slot phase0.Slot) time.Duration { intervalsPerSlot := 3 - if b.IsGloas(b.EstimatedEpochAtSlot(slot)) { + if b.IsGloasAtSlot(slot) { intervalsPerSlot = 4 } return b.SlotDuration / time.Duration(intervalsPerSlot) @@ -202,6 +202,12 @@ func (b *Beacon) IsGloas(epoch phase0.Epoch) bool { return ok && epoch >= fork.Epoch } +// IsGloasAtSlot reports whether the Gloas (ePBS) fork is active at the given slot — the slot-keyed +// shorthand for IsGloas(EstimatedEpochAtSlot(slot)) used across the duty runners and validators. +func (b *Beacon) IsGloasAtSlot(slot phase0.Slot) bool { + return b.IsGloas(b.EstimatedEpochAtSlot(slot)) +} + // GloasForkEpoch returns the scheduled Gloas (ePBS) fork epoch and whether a Gloas fork is present in // the schedule. An unscheduled far-future epoch is returned as-is; callers that gate on it (IsGloas, // InGloasPriorWindow) treat it as never active via the epoch comparison. diff --git a/operator/duties/validator_registration.go b/operator/duties/validator_registration.go index 53bce515f6..83392d28a5 100644 --- a/operator/duties/validator_registration.go +++ b/operator/duties/validator_registration.go @@ -162,7 +162,7 @@ func (h *ValidatorRegistrationHandler) HandleDuties(ctx context.Context) { } dutySlot := blockSlot + validatorRegistrationDutySlotsToPostpone // Deprecated at the Gloas fork: don't enqueue registrations whose duty slot is Gloas-or-later. - if h.netCfg.IsGloas(h.netCfg.EstimatedEpochAtSlot(dutySlot)) { + if h.netCfg.IsGloasAtSlot(dutySlot) { continue } earliestExecutionSlot := blockSlot + validatorRegistrationExecutionSlotsToPostpone diff --git a/protocol/v2/ssv/runner/committee.go b/protocol/v2/ssv/runner/committee.go index f2e937a480..936fdaece9 100644 --- a/protocol/v2/ssv/runner/committee.go +++ b/protocol/v2/ssv/runner/committee.go @@ -228,7 +228,7 @@ func (r *CommitteeRunner) ProcessConsensus(ctx context.Context, logger *zap.Logg // The decided value is a GloasBeaconVote (which carries the attestation index) on Gloas slots, a // plain BeaconVote before; decode into the matching prototype. decidedPrototype := spectypes.Encoder(&spectypes.BeaconVote{}) - if dutyErr == nil && r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(committeeDuty.DutySlot())) { + if dutyErr == nil && r.NetworkConfig.IsGloasAtSlot(committeeDuty.DutySlot()) { decidedPrototype = &gloas.GloasBeaconVote{} } @@ -1176,7 +1176,7 @@ func (r *CommitteeRunner) executeDuty(ctx context.Context, logger *zap.Logger, d // index (SIP #94 §2); before Gloas it is a plain BeaconVote. Both implement spectypes.Encoder, so // the QBFT plumbing is identical — only the value type and its checker differ. var input spectypes.Encoder - if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(slot)) { + if r.NetworkConfig.IsGloasAtSlot(slot) { gloasVote := &gloas.GloasBeaconVote{ BlockRoot: attData.BeaconBlockRoot, Source: attData.Source, diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 1aa1db5c4c..8e97f8996d 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -184,7 +184,7 @@ func (r *ProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Lo // isn't leading the 1st QBFT round it might become a Leader in case of round change - hence // we are always fetching Ethereum block here just in case we need to propose it). var input *spectypes.ProposerConsensusData - if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(duty.Slot)) { + if r.NetworkConfig.IsGloasAtSlot(duty.Slot) { input, err = r.gloasProposalInput(ctx, logger, duty, fullSig) if err != nil { return err @@ -310,7 +310,7 @@ func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logge ) var blkRootToSign ssz.HashRoot - if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + if r.NetworkConfig.IsGloasAtSlot(cd.Duty.Slot) { // Gloas blocks have no spectypes block version; decode the node-side block, which doubles as // the ssz.HashRoot to sign. block, decErr := gloas.DecodeBeaconBlock(cd.DataSSZ) @@ -457,7 +457,7 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L return fmt.Errorf("could not decode decided validator consensus data: %w", err) } - if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(validatorConsensusData.Duty.Slot)) { + if r.NetworkConfig.IsGloasAtSlot(validatorConsensusData.Duty.Slot) { return r.submitGloasProposal(ctx, logger, span, validatorConsensusData, specSig) } @@ -568,7 +568,7 @@ func (r *ProposerRunner) expectedPostConsensusRootsAndDomain(context.Context) ([ } var signedRoot ssz.HashRoot - if r.NetworkConfig.IsGloas(r.NetworkConfig.EstimatedEpochAtSlot(validatorConsensusData.Duty.Slot)) { + if r.NetworkConfig.IsGloasAtSlot(validatorConsensusData.Duty.Slot) { block, decErr := gloas.DecodeBeaconBlock(validatorConsensusData.DataSSZ) if decErr != nil { return nil, phase0.DomainType{}, fmt.Errorf("could not decode gloas block: %w", decErr) diff --git a/protocol/v2/ssv/runner/runner_validations.go b/protocol/v2/ssv/runner/runner_validations.go index 9f484bffb2..fed8cc5cce 100644 --- a/protocol/v2/ssv/runner/runner_validations.go +++ b/protocol/v2/ssv/runner/runner_validations.go @@ -145,7 +145,7 @@ func (b *BaseRunner) ValidatePostConsensusMsg(ctx context.Context, runner Runner // Parse-check the decided value against the slot's fork: a GloasBeaconVote (120B) on Gloas, // a BeaconVote (112B) before. The two reject on length, so a wrong-fork value fails here. decidedValue := spectypes.Encoder(&spectypes.BeaconVote{}) - if b.NetworkConfig.IsGloas(b.NetworkConfig.EstimatedEpochAtSlot(expectedSlot)) { + if b.NetworkConfig.IsGloasAtSlot(expectedSlot) { decidedValue = &gloas.GloasBeaconVote{} } if err := decidedValue.Decode(decidedValueBytes); err != nil { diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index ef9e2d6c5a..3e90d1e8c2 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -248,7 +248,7 @@ func (v *proposerChecker) CheckValue(value []byte) error { } var slot phase0.Slot - if v.beaconConfig.IsGloas(v.beaconConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + if v.beaconConfig.IsGloasAtSlot(cd.Duty.Slot) { // Gloas blocks have no spectypes block version; GetBlockData can't decode them, so read the // slot from the node-side block directly. block, decErr := gloas.DecodeBeaconBlock(cd.DataSSZ) @@ -327,7 +327,7 @@ func checkValidatorConsensusData( return nil, fmt.Errorf("failed decoding consensus data: %w", err) } - if cd.Duty.Type == spectypes.BNRoleProposer && beaconConfig.IsGloas(beaconConfig.EstimatedEpochAtSlot(cd.Duty.Slot)) { + if cd.Duty.Type == spectypes.BNRoleProposer && beaconConfig.IsGloasAtSlot(cd.Duty.Slot) { // Gloas blocks have no spectypes block version, so ValidateConsensusData's GetBlockData path // can't decode them; a successful node-side decode is the validity check. if _, err := gloas.DecodeBeaconBlock(cd.DataSSZ); err != nil { From 06969e34d6ca4d48e2196eb15146f0518d86c979 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 25 Jun 2026 23:31:08 +0300 Subject: [PATCH 042/150] gloas: type ExecutionPayloadBid.PrevRandao as Hash32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prev_randao is a Bytes32 (the RANDAO mix), not a Merkle root — type it as phase0.Hash32 to match its sibling BlockHash/ParentBlockHash fields. The SSZ is byte-identical (both 32 bytes), so only the generated Hash comment changes. --- protocol/v2/types/gloas/execution_payload_bid.go | 2 +- protocol/v2/types/gloas/execution_payload_bid_encoding.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol/v2/types/gloas/execution_payload_bid.go b/protocol/v2/types/gloas/execution_payload_bid.go index 2f07cd18bc..3e4c46580f 100644 --- a/protocol/v2/types/gloas/execution_payload_bid.go +++ b/protocol/v2/types/gloas/execution_payload_bid.go @@ -26,7 +26,7 @@ type ExecutionPayloadBid struct { ParentBlockHash phase0.Hash32 `ssz-size:"32"` ParentBlockRoot phase0.Root `ssz-size:"32"` BlockHash phase0.Hash32 `ssz-size:"32"` - PrevRandao phase0.Root `ssz-size:"32"` + PrevRandao phase0.Hash32 `ssz-size:"32"` FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` GasLimit uint64 BuilderIndex BuilderIndex diff --git a/protocol/v2/types/gloas/execution_payload_bid_encoding.go b/protocol/v2/types/gloas/execution_payload_bid_encoding.go index 7cc02bf46a..e59f629311 100644 --- a/protocol/v2/types/gloas/execution_payload_bid_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_bid_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: d920b162beb9c1bebf50a7f000cc16d5686f314a330bb3a3520d5c1932290c94 +// Hash: 96ab5c5b5fa31abaf95dfca1088f2821793e0442ab69d4fef607a3c534b58289 // Version: 0.1.3 package gloas From 70378e51c394a2daa6a9269d13227e9483a2e22b Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 10:41:54 +0300 Subject: [PATCH 043/150] =?UTF-8?q?gloas:=20add=20=C2=A76=20envelope=20QBF?= =?UTF-8?q?T=20types=20(SIP=20#94=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the two node-side types the envelope-signing duty agrees on: - BlindedExecutionPayloadEnvelope: the Gloas ExecutionPayloadEnvelope with the full payload replaced by PayloadRoot = hash_tree_root(payload). Its hash-tree root equals the full envelope's (SSZ positional merkleization), so a signature over the blinded root is valid for the full envelope while keeping the QBFT value bounded. - EnvelopeConsensusData: the §6 QBFT value (Duty + Version + DataSSZ), a distinct type so the envelope path reads as its own role rather than borrowing the proposer's; a test proves it stays wire-identical to ProposerConsensusData (the cross-client QBFT value format) so the standalone type can't drift. The full ExecutionPayloadEnvelope (needs the Gloas ExecutionPayload + BlockAccessList, in no dependency yet) and the EnvelopeBuilder runner follow. --- .../v2/types/gloas/envelope_consensus_data.go | 24 +++ .../gloas/envelope_consensus_data_encoding.go | 145 ++++++++++++++++++ .../gloas/envelope_consensus_data_test.go | 41 +++++ .../types/gloas/execution_payload_envelope.go | 28 ++++ .../execution_payload_envelope_encoding.go | 135 ++++++++++++++++ .../gloas/execution_payload_envelope_test.go | 35 +++++ 6 files changed, 408 insertions(+) create mode 100644 protocol/v2/types/gloas/envelope_consensus_data.go create mode 100644 protocol/v2/types/gloas/envelope_consensus_data_encoding.go create mode 100644 protocol/v2/types/gloas/envelope_consensus_data_test.go create mode 100644 protocol/v2/types/gloas/execution_payload_envelope.go create mode 100644 protocol/v2/types/gloas/execution_payload_envelope_encoding.go create mode 100644 protocol/v2/types/gloas/execution_payload_envelope_test.go diff --git a/protocol/v2/types/gloas/envelope_consensus_data.go b/protocol/v2/types/gloas/envelope_consensus_data.go new file mode 100644 index 0000000000..b39d2dc701 --- /dev/null +++ b/protocol/v2/types/gloas/envelope_consensus_data.go @@ -0,0 +1,24 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec" + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +// Regenerate with `go generate ./...`. ValidatorDuty resolves from ssv-spec and DataVersion from +// go-eth2-client/spec; both are tracked via the module graph (`go list -m`) rather than pinned. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./envelope_consensus_data.go --include $(go list -m -f '{{.Dir}}' github.com/ssvlabs/ssv-spec)/types,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs EnvelopeConsensusData" + +// EnvelopeConsensusData is the §6 QBFT value for the envelope-signing duty (SIP #94 §6). It shares +// spectypes.ProposerConsensusData's wire shape (Duty + Version + DataSSZ) but is a distinct type so the +// envelope path reads as its own role rather than borrowing the proposer's. DataSSZ carries the +// SSZ-encoded BlindedExecutionPayloadEnvelope. +type EnvelopeConsensusData struct { + Duty spectypes.ValidatorDuty + Version spec.DataVersion + DataSSZ []byte `ssz-max:"8388608"` +} + +// Encode/Decode wrap SSZ (de)serialization — the form the §6 QBFT instance agrees on. +func (e *EnvelopeConsensusData) Encode() ([]byte, error) { return e.MarshalSSZ() } +func (e *EnvelopeConsensusData) Decode(data []byte) error { return e.UnmarshalSSZ(data) } diff --git a/protocol/v2/types/gloas/envelope_consensus_data_encoding.go b/protocol/v2/types/gloas/envelope_consensus_data_encoding.go new file mode 100644 index 0000000000..f233df8d3e --- /dev/null +++ b/protocol/v2/types/gloas/envelope_consensus_data_encoding.go @@ -0,0 +1,145 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 2584b9344ef92a617476649180e7fbe0ce01af59f5f5ee00dbbc4c36c057d595 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the EnvelopeConsensusData object +func (e *EnvelopeConsensusData) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(e) +} + +// MarshalSSZTo ssz marshals the EnvelopeConsensusData object to a target array +func (e *EnvelopeConsensusData) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(16) + + // Offset (0) 'Duty' + dst = ssz.WriteOffset(dst, offset) + offset += e.Duty.SizeSSZ() + + // Field (1) 'Version' + dst = ssz.MarshalUint64(dst, uint64(e.Version)) + + // Offset (2) 'DataSSZ' + dst = ssz.WriteOffset(dst, offset) + + // Field (0) 'Duty' + if dst, err = e.Duty.MarshalSSZTo(dst); err != nil { + return + } + + // Field (2) 'DataSSZ' + if size := len(e.DataSSZ); size > 8388608 { + err = ssz.ErrBytesLengthFn("EnvelopeConsensusData.DataSSZ", size, 8388608) + return + } + dst = append(dst, e.DataSSZ...) + + return +} + +// UnmarshalSSZ ssz unmarshals the EnvelopeConsensusData object +func (e *EnvelopeConsensusData) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 16 { + return ssz.ErrSize + } + + tail := buf + var o0, o2 uint64 + + // Offset (0) 'Duty' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 16 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Version' + e.Version = spec.DataVersion(ssz.UnmarshallUint64(buf[4:12])) + + // Offset (2) 'DataSSZ' + if o2 = ssz.ReadOffset(buf[12:16]); o2 > size || o0 > o2 { + return ssz.ErrOffset + } + + // Field (0) 'Duty' + { + buf = tail[o0:o2] + if err = e.Duty.UnmarshalSSZ(buf); err != nil { + return err + } + } + + // Field (2) 'DataSSZ' + { + buf = tail[o2:] + if len(buf) > 8388608 { + return ssz.ErrBytesLength + } + if cap(e.DataSSZ) == 0 { + e.DataSSZ = make([]byte, 0, len(buf)) + } + e.DataSSZ = append(e.DataSSZ, buf...) + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the EnvelopeConsensusData object +func (e *EnvelopeConsensusData) SizeSSZ() (size int) { + size = 16 + + // Field (0) 'Duty' + size += e.Duty.SizeSSZ() + + // Field (2) 'DataSSZ' + size += len(e.DataSSZ) + + return +} + +// HashTreeRoot ssz hashes the EnvelopeConsensusData object +func (e *EnvelopeConsensusData) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(e) +} + +// HashTreeRootWith ssz hashes the EnvelopeConsensusData object with a hasher +func (e *EnvelopeConsensusData) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Duty' + if err = e.Duty.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Version' + hh.PutUint64(uint64(e.Version)) + + // Field (2) 'DataSSZ' + { + elemIndx := hh.Index() + byteLen := uint64(len(e.DataSSZ)) + if byteLen > 8388608 { + err = ssz.ErrIncorrectListSize + return + } + hh.Append(e.DataSSZ) + hh.MerkleizeWithMixin(elemIndx, byteLen, (8388608+31)/32) + } + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the EnvelopeConsensusData object +func (e *EnvelopeConsensusData) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(e) +} diff --git a/protocol/v2/types/gloas/envelope_consensus_data_test.go b/protocol/v2/types/gloas/envelope_consensus_data_test.go new file mode 100644 index 0000000000..e50bed3d85 --- /dev/null +++ b/protocol/v2/types/gloas/envelope_consensus_data_test.go @@ -0,0 +1,41 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" +) + +// EnvelopeConsensusData must stay wire-identical to spectypes.ProposerConsensusData (the same +// {Duty, Version, DataSSZ} shape) so the §6 QBFT value byte-matches what other clients encode in a +// mixed cluster. This is what justifies a distinct node-side type instead of reusing the spec one. +func TestEnvelopeConsensusDataWireMatchesProposerConsensusData(t *testing.T) { + duty := spectypes.ValidatorDuty{ + Type: spectypes.BNRoleEnvelopeBuilder, + PubKey: phase0.BLSPubKey{0x01}, + Slot: 7, + ValidatorIndex: 3, + } + dataSSZ := []byte{0x0a, 0x0b, 0x0c} + + env := &EnvelopeConsensusData{Duty: duty, Version: spec.DataVersionFulu, DataSSZ: dataSSZ} + prop := &spectypes.ProposerConsensusData{Duty: duty, Version: spec.DataVersionFulu, DataSSZ: dataSSZ} + + envBytes, err := env.Encode() + require.NoError(t, err) + propBytes, err := prop.Encode() + require.NoError(t, err) + require.Equal(t, propBytes, envBytes, "envelope consensus data must encode identically to proposer consensus data") + + // Round-trip at the wire level — SSZ decodes a nil slice back as empty, so compare bytes, not structs. + out := &EnvelopeConsensusData{} + require.NoError(t, out.Decode(envBytes)) + reEncoded, err := out.Encode() + require.NoError(t, err) + require.Equal(t, envBytes, reEncoded) + require.Equal(t, dataSSZ, out.DataSSZ) + require.Equal(t, duty.ValidatorIndex, out.Duty.ValidatorIndex) +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go new file mode 100644 index 0000000000..4ad2114668 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -0,0 +1,28 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. The includes are resolved from the module graph (`go list -m`) +// so they track go-eth2-client across dependency bumps rather than pinning. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./execution_payload_envelope.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs BlindedExecutionPayloadEnvelope" + +// BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 +// envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by +// PayloadRoot = hash_tree_root(payload). By SSZ Container positional merkleization its hash-tree root +// equals the full envelope's, so a BLS signature over the blinded signing root is valid for the full +// SignedExecutionPayloadEnvelope. It rides in EnvelopeConsensusData.DataSSZ; blinding keeps that QBFT +// value bounded — a few hundred bytes rather than the full payload's hundreds of KB to ~MB. +type BlindedExecutionPayloadEnvelope struct { + PayloadRoot phase0.Root `ssz-size:"32"` + ExecutionRequests *electra.ExecutionRequests + BuilderIndex uint64 + BeaconBlockRoot phase0.Root `ssz-size:"32"` + ParentBeaconBlockRoot phase0.Root `ssz-size:"32"` +} + +// Encode/Decode wrap SSZ (de)serialization — the form carried in the §6 QBFT consensus DataSSZ. +func (b *BlindedExecutionPayloadEnvelope) Encode() ([]byte, error) { return b.MarshalSSZ() } +func (b *BlindedExecutionPayloadEnvelope) Decode(data []byte) error { return b.UnmarshalSSZ(data) } diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go new file mode 100644 index 0000000000..c0dadfc9ea --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -0,0 +1,135 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 6f5baa3e493219b71f0a6c2e2c7c996d65da9ea3c31ccc683b80b4565d4c3677 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/electra" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the BlindedExecutionPayloadEnvelope object +func (b *BlindedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(b) +} + +// MarshalSSZTo ssz marshals the BlindedExecutionPayloadEnvelope object to a target array +func (b *BlindedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(108) + + // Field (0) 'PayloadRoot' + dst = append(dst, b.PayloadRoot[:]...) + + // Offset (1) 'ExecutionRequests' + dst = ssz.WriteOffset(dst, offset) + + // Field (2) 'BuilderIndex' + dst = ssz.MarshalUint64(dst, b.BuilderIndex) + + // Field (3) 'BeaconBlockRoot' + dst = append(dst, b.BeaconBlockRoot[:]...) + + // Field (4) 'ParentBeaconBlockRoot' + dst = append(dst, b.ParentBeaconBlockRoot[:]...) + + // Field (1) 'ExecutionRequests' + if dst, err = b.ExecutionRequests.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the BlindedExecutionPayloadEnvelope object +func (b *BlindedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 108 { + return ssz.ErrSize + } + + tail := buf + var o1 uint64 + + // Field (0) 'PayloadRoot' + copy(b.PayloadRoot[:], buf[0:32]) + + // Offset (1) 'ExecutionRequests' + if o1 = ssz.ReadOffset(buf[32:36]); o1 > size { + return ssz.ErrOffset + } + + if o1 != 108 { + return ssz.ErrInvalidVariableOffset + } + + // Field (2) 'BuilderIndex' + b.BuilderIndex = ssz.UnmarshallUint64(buf[36:44]) + + // Field (3) 'BeaconBlockRoot' + copy(b.BeaconBlockRoot[:], buf[44:76]) + + // Field (4) 'ParentBeaconBlockRoot' + copy(b.ParentBeaconBlockRoot[:], buf[76:108]) + + // Field (1) 'ExecutionRequests' + { + buf = tail[o1:] + if b.ExecutionRequests == nil { + b.ExecutionRequests = new(electra.ExecutionRequests) + } + if err = b.ExecutionRequests.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the BlindedExecutionPayloadEnvelope object +func (b *BlindedExecutionPayloadEnvelope) SizeSSZ() (size int) { + size = 108 + + // Field (1) 'ExecutionRequests' + if b.ExecutionRequests == nil { + b.ExecutionRequests = new(electra.ExecutionRequests) + } + size += b.ExecutionRequests.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the BlindedExecutionPayloadEnvelope object +func (b *BlindedExecutionPayloadEnvelope) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(b) +} + +// HashTreeRootWith ssz hashes the BlindedExecutionPayloadEnvelope object with a hasher +func (b *BlindedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'PayloadRoot' + hh.PutBytes(b.PayloadRoot[:]) + + // Field (1) 'ExecutionRequests' + if err = b.ExecutionRequests.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'BuilderIndex' + hh.PutUint64(b.BuilderIndex) + + // Field (3) 'BeaconBlockRoot' + hh.PutBytes(b.BeaconBlockRoot[:]) + + // Field (4) 'ParentBeaconBlockRoot' + hh.PutBytes(b.ParentBeaconBlockRoot[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the BlindedExecutionPayloadEnvelope object +func (b *BlindedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(b) +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go new file mode 100644 index 0000000000..3061078025 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -0,0 +1,35 @@ +package gloas + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// A blinded envelope round-trips through SSZ and its root is stable. +func TestBlindedExecutionPayloadEnvelopeRoundTrip(t *testing.T) { + in := &BlindedExecutionPayloadEnvelope{ + PayloadRoot: phase0.Root{0x01}, + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: uint64(BuilderIndexSelfBuild), + BeaconBlockRoot: phase0.Root{0x02}, + ParentBeaconBlockRoot: phase0.Root{0x03}, + } + b, err := in.MarshalSSZ() + require.NoError(t, err) + + out := &BlindedExecutionPayloadEnvelope{} + require.NoError(t, out.UnmarshalSSZ(b)) + require.Equal(t, in.PayloadRoot, out.PayloadRoot) + require.Equal(t, uint64(BuilderIndexSelfBuild), out.BuilderIndex) + require.Equal(t, in.BeaconBlockRoot, out.BeaconBlockRoot) + require.Equal(t, in.ParentBeaconBlockRoot, out.ParentBeaconBlockRoot) + + r1, err := in.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} From 8dfc7f9ae03a891c387d745ce80f069d1b206f42 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 11:40:18 +0300 Subject: [PATCH 044/150] =?UTF-8?q?gloas:=20add=20=C2=A76=20envelope=20val?= =?UTF-8?q?ue-check=20+=20decided-block-root=20store=20(SIP=20#94=20=C2=A7?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §6 envelope-signing duty's value-check needs the §4-decided block root, which lives in the proposer runner. Add the shared plumbing both the runner and the value-check use: - ProposedBlockRoots: a per-validator slot -> root store. Lives in package ssv so the envelope runner and value-check can read it without an import cycle (runner already imports ssv). The proposer runner records its §4-decided root; the envelope runner and its value-check read it. - NewEnvelopeChecker: validates an EnvelopeConsensusData carrying a self-build BlindedExecutionPayloadEnvelope whose BeaconBlockRoot matches the slot's §4-decided root. Envelope content is leader-trusted (no field validation), matching the blinded-block trust model in the proposer path. Wiring (proposer write, the EnvelopeBuilder runner, registration) follows. --- protocol/v2/ssv/proposed_block_roots.go | 46 +++++++++++++ protocol/v2/ssv/proposed_block_roots_test.go | 28 ++++++++ protocol/v2/ssv/value_check.go | 64 ++++++++++++++++++ protocol/v2/ssv/value_check_test.go | 71 ++++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 protocol/v2/ssv/proposed_block_roots.go create mode 100644 protocol/v2/ssv/proposed_block_roots_test.go diff --git a/protocol/v2/ssv/proposed_block_roots.go b/protocol/v2/ssv/proposed_block_roots.go new file mode 100644 index 0000000000..240d105fa4 --- /dev/null +++ b/protocol/v2/ssv/proposed_block_roots.go @@ -0,0 +1,46 @@ +package ssv + +import ( + "sync" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// proposedBlockRootRetention bounds how many slots of decided block roots to keep. The §6 envelope +// runner reads the root for its own slot, written by the §4 proposer runner moments earlier, so a +// small window is plenty. +const proposedBlockRootRetention = 4 + +// ProposedBlockRoots records, per slot, the block root the proposer runner decided in §4 so the §6 +// envelope runner (and its value-check) can read the same root — the envelope's BeaconBlockRoot must +// match the §4-decided block (SIP #94 §6). It is shared between a single validator's proposer and +// envelope runners; lives in package ssv so both the runner and value-check can use it without an +// import cycle. Safe for concurrent use. +type ProposedBlockRoots struct { + mu sync.Mutex + roots map[phase0.Slot]phase0.Root +} + +func NewProposedBlockRoots() *ProposedBlockRoots { + return &ProposedBlockRoots{roots: make(map[phase0.Slot]phase0.Root)} +} + +// Set records the §4-decided block root for the slot and evicts roots older than the retention window. +func (s *ProposedBlockRoots) Set(slot phase0.Slot, root phase0.Root) { + s.mu.Lock() + defer s.mu.Unlock() + s.roots[slot] = root + for sl := range s.roots { + if slot > proposedBlockRootRetention && sl < slot-proposedBlockRootRetention { + delete(s.roots, sl) + } + } +} + +// Get returns the §4-decided block root recorded for the slot, if any. +func (s *ProposedBlockRoots) Get(slot phase0.Slot) (phase0.Root, bool) { + s.mu.Lock() + defer s.mu.Unlock() + root, ok := s.roots[slot] + return root, ok +} diff --git a/protocol/v2/ssv/proposed_block_roots_test.go b/protocol/v2/ssv/proposed_block_roots_test.go new file mode 100644 index 0000000000..a4ce3b195f --- /dev/null +++ b/protocol/v2/ssv/proposed_block_roots_test.go @@ -0,0 +1,28 @@ +package ssv + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestProposedBlockRoots(t *testing.T) { + s := NewProposedBlockRoots() + + _, ok := s.Get(5) + require.False(t, ok) + + s.Set(5, phase0.Root{0x01}) + root, ok := s.Get(5) + require.True(t, ok) + require.Equal(t, phase0.Root{0x01}, root) + + // A far-future slot evicts roots beyond the retention window. + s.Set(20, phase0.Root{0x02}) + _, ok = s.Get(5) + require.False(t, ok, "slot 5 should be evicted beyond the retention window") + root, ok = s.Get(20) + require.True(t, ok) + require.Equal(t, phase0.Root{0x02}, root) +} diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index 3e90d1e8c2..fec370eb60 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -150,6 +150,70 @@ func (v *gloasVoteChecker) CheckValue(value []byte) error { return nil } +type envelopeChecker struct { + proposedBlockRoots *ProposedBlockRoots + slot phase0.Slot + validatorPK spectypes.ValidatorPK + validatorIndex phase0.ValidatorIndex +} + +// NewEnvelopeChecker validates the §6 envelope-signing duty's QBFT value (SIP #94 §6): an +// EnvelopeConsensusData carrying a self-build BlindedExecutionPayloadEnvelope whose BeaconBlockRoot +// matches the §4-decided block root for the slot (read from the store the proposer runner wrote). The +// envelope's content is leader-trusted — no PayloadRoot/field validation — matching the blinded-block +// trust model in the proposer path. +func NewEnvelopeChecker( + proposedBlockRoots *ProposedBlockRoots, + slot phase0.Slot, + validatorPK spectypes.ValidatorPK, + validatorIndex phase0.ValidatorIndex, +) ValueChecker { + return &envelopeChecker{ + proposedBlockRoots: proposedBlockRoots, + slot: slot, + validatorPK: validatorPK, + validatorIndex: validatorIndex, + } +} + +func (v *envelopeChecker) CheckValue(value []byte) error { + cd := &gloas.EnvelopeConsensusData{} + if err := cd.Decode(value); err != nil { + return spectypes.WrapError(spectypes.QBFTValueInvalidErrorCode, fmt.Errorf("failed decoding envelope consensus data: %w", err)) + } + + if cd.Duty.Slot != v.slot { + return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "wrong envelope duty slot") + } + if cd.Duty.ValidatorIndex != v.validatorIndex { + return spectypes.NewError(spectypes.WrongValidatorIndexErrorCode, "wrong validator index") + } + if !bytes.Equal(cd.Duty.PubKey[:], v.validatorPK[:]) { + return spectypes.NewError(spectypes.WrongValidatorPubkeyErrorCode, "wrong validator pk") + } + + blinded := &gloas.BlindedExecutionPayloadEnvelope{} + if err := blinded.Decode(cd.DataSSZ); err != nil { + return spectypes.WrapError(spectypes.QBFTValueInvalidErrorCode, fmt.Errorf("failed decoding blinded envelope: %w", err)) + } + + // This duty applies only to the self-build path; external builders sign their own envelopes. + if blinded.BuilderIndex != uint64(gloas.BuilderIndexSelfBuild) { + return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "envelope builder index is not self-build") + } + + // The envelope must commit to the block the §4 QBFT decided for this slot. + decidedRoot, ok := v.proposedBlockRoots.Get(v.slot) + if !ok { + return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "no decided block root for envelope slot") + } + if blinded.BeaconBlockRoot != decidedRoot { + return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "envelope beacon block root does not match the decided block") + } + + return nil +} + type aggregatorCommitteeChecker struct{} func NewAggregatorCommitteeChecker() ValueChecker { diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 4ba68017b3..61423ceaeb 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" @@ -243,3 +244,73 @@ func TestProposerChecker_GloasDecodeError(t *testing.T) { checker := newGloasProposerChecker(fakeSlashingSigner{}) require.Error(t, checker.CheckValue(gloasProposerConsensusData(t, []byte{0x00, 0x01, 0x02}))) } + +// --- envelope checker, §6 --- + +var envelopeValidatorPK = phase0.BLSPubKey{0x42} + +func encodeEnvelopeValue(t *testing.T, slot phase0.Slot, valIdx phase0.ValidatorIndex, pk phase0.BLSPubKey, blockRoot phase0.Root, builderIndex uint64) []byte { + t.Helper() + blinded := &gloas.BlindedExecutionPayloadEnvelope{ + PayloadRoot: phase0.Root{0x09}, + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: builderIndex, + BeaconBlockRoot: blockRoot, + ParentBeaconBlockRoot: phase0.Root{0x08}, + } + dataSSZ, err := blinded.Encode() + require.NoError(t, err) + cd := &gloas.EnvelopeConsensusData{ + Duty: spectypes.ValidatorDuty{ + Type: spectypes.BNRoleEnvelopeBuilder, + Slot: slot, + ValidatorIndex: valIdx, + PubKey: pk, + }, + DataSSZ: dataSSZ, + } + out, err := cd.Encode() + require.NoError(t, err) + return out +} + +func newEnvelopeCheckerWithRoot(slot phase0.Slot, root phase0.Root) ValueChecker { + store := NewProposedBlockRoots() + store.Set(slot, root) + return NewEnvelopeChecker(store, slot, spectypes.ValidatorPK(envelopeValidatorPK), 3) +} + +// A self-build envelope whose BeaconBlockRoot matches the §4-decided root for the slot passes. +func TestEnvelopeChecker_Valid(t *testing.T) { + root := phase0.Root{0xaa} + checker := newEnvelopeCheckerWithRoot(7, root) + require.NoError(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, root, uint64(gloas.BuilderIndexSelfBuild)))) +} + +func TestEnvelopeChecker_NotSelfBuild(t *testing.T) { + root := phase0.Root{0xaa} + checker := newEnvelopeCheckerWithRoot(7, root) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, root, 5))) +} + +func TestEnvelopeChecker_WrongBlockRoot(t *testing.T) { + checker := newEnvelopeCheckerWithRoot(7, phase0.Root{0xaa}) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xbb}, uint64(gloas.BuilderIndexSelfBuild)))) +} + +// The §4 root must be present — the proposer runner must have decided and recorded it. +func TestEnvelopeChecker_NoDecidedRoot(t *testing.T) { + checker := NewEnvelopeChecker(NewProposedBlockRoots(), 7, spectypes.ValidatorPK(envelopeValidatorPK), 3) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xaa}, uint64(gloas.BuilderIndexSelfBuild)))) +} + +func TestEnvelopeChecker_WrongSlot(t *testing.T) { + root := phase0.Root{0xaa} + checker := newEnvelopeCheckerWithRoot(7, root) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 8, 3, envelopeValidatorPK, root, uint64(gloas.BuilderIndexSelfBuild)))) +} + +func TestEnvelopeChecker_DecodeError(t *testing.T) { + checker := newEnvelopeCheckerWithRoot(7, phase0.Root{0xaa}) + require.Error(t, checker.CheckValue([]byte{0x00, 0x01})) +} From 616bb016a207041b4540a9799c0d84c5f9ff64ca Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 13:00:34 +0300 Subject: [PATCH 045/150] =?UTF-8?q?gloas:=20wire=20the=20proposer=20side?= =?UTF-8?q?=20of=20=C2=A76=20=E2=80=94=20record=20the=20=C2=A74=20root=20+?= =?UTF-8?q?=20trigger=20the=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hooks the §6 envelope-signing duty depends on: - recordDecidedBlockRoot: the proposer stores its §4-decided block root (in ProcessConsensus) into the shared ProposedBlockRoots store, so every operator's envelope value-check can match the envelope's BeaconBlockRoot against it. - triggerEnvelopeIfSelfBuild: after publishing the block, submitGloasProposal starts the §6 envelope duty, but only on the self-build path (external builders sign their own envelope) — the rare case. It fires on every operator (all must join the envelope round) and runs async after publication, so it never delays the block. Reading the bid for the self-build check requires decoding the decided block on every operator, so the decode now precedes the content match — superseding the earlier "skip decode for non-builders" optimization, since that decode is needed. StartEnvelopeDuty is nil until the controller wires it to the EnvelopeBuilder runner (next); the store is created per validator in SetupRunners. --- operator/validator/controller.go | 5 ++ protocol/v2/ssv/runner/proposer.go | 84 ++++++++++++++++++++----- protocol/v2/ssv/runner/proposer_test.go | 74 ++++++++++++++++++++++ 3 files changed, 147 insertions(+), 16 deletions(-) diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 73edcb2239..bc8fe67c65 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1207,6 +1207,10 @@ func SetupRunners( OperatorSigner: options.OperatorSigner, } + // proposedBlockRoots is shared between this validator's proposer runner (which records its + // §4-decided block root) and the §6 envelope runner (which reads it). + proposedBlockRoots := ssv.NewProposedBlockRoots() + runners := runner.ValidatorDutyRunners{} var err error for _, role := range runnersType { @@ -1221,6 +1225,7 @@ func SetupRunners( HighestDecidedSlot: 0, Graffiti: options.Graffiti, ProposerDelay: options.ProposerDelay, + ProposedBlockRoots: proposedBlockRoots, }) case ssvtypes.RoleAggregator: // Post-Boole, aggregator duties route through the merged AggregatorCommitteeRunner diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 8e97f8996d..02b07923cd 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -65,6 +65,14 @@ type ProposerRunner struct { // Post-consensus content-matches it against the decided value to detect whether this operator // built the decided block — only that operator publishes it (and can later reveal its payload). cachedGloasBlockSSZ []byte + + // proposedBlockRoots records this operator's §4-decided block root per slot so the §6 envelope + // runner and its value-check can read it (SIP #94 §6); nil pre-Gloas. + proposedBlockRoots *ssv.ProposedBlockRoots + + // startEnvelopeDuty starts the §6 envelope-signing duty for a slot, called after a self-build §4 + // block is published. Injected by the controller; nil pre-Gloas / when the envelope runner is absent. + startEnvelopeDuty func(ctx context.Context, slot phase0.Slot) } // ProposerRunnerOptions bundles all dependencies required by NewProposerRunner. @@ -80,6 +88,14 @@ type ProposerRunnerOptions struct { // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract // higher MEV. ProposerDelay time.Duration + + // ProposedBlockRoots is the shared store the proposer records its §4-decided block root into for the + // §6 envelope runner to read. Optional (nil pre-Gloas / when the envelope runner is absent). + ProposedBlockRoots *ssv.ProposedBlockRoots + + // StartEnvelopeDuty starts the §6 envelope-signing duty for a slot; called after a self-build §4 + // block is published. Optional. + StartEnvelopeDuty func(ctx context.Context, slot phase0.Slot) } func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { @@ -105,7 +121,9 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { measurements: newMeasurementsStore(), graffiti: opts.Graffiti, - proposerDelay: opts.ProposerDelay, + proposerDelay: opts.ProposerDelay, + proposedBlockRoots: opts.ProposedBlockRoots, + startEnvelopeDuty: opts.StartEnvelopeDuty, }, nil } @@ -318,6 +336,9 @@ func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logge return fmt.Errorf("could not decode gloas block from consensus data: %w", decErr) } blkRootToSign = block + if err := r.recordDecidedBlockRoot(cd.Duty.Slot, block); err != nil { + return err + } span.AddEvent("decided has a gloas block") } else { versionedBlock, signingRoot, err := cd.GetBlockData() @@ -524,30 +545,61 @@ func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *za return nil } -// submitGloasProposal publishes the decided Gloas (ePBS) block, but only from the operator that built -// it: the decided value is content-matched against this operator's cached block. Only that operator can -// later reveal the matching payload in the §6 envelope, so the others complete the duty without -// submitting (the builder publishes). +// submitGloasProposal publishes the decided Gloas (ePBS) block — only the builder (content match) +// submits, the others just complete the duty — then on the self-build path starts the §6 +// envelope-signing duty. The block is decoded up front because every operator needs its bid for the +// self-build check; the trigger runs after publication and async, so it never delays the block. func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { - if !bytes.Equal(r.cachedGloasBlockSSZ, cd.DataSSZ) { + block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) + if err != nil { + return fmt.Errorf("could not decode decided gloas block: %w", err) + } + + var finishErr error + if bytes.Equal(r.cachedGloasBlockSSZ, cd.DataSSZ) { + start := time.Now() + signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} + if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { + recordFailedSubmission(ctx, spectypes.BNRoleProposer) + return fmt.Errorf("submit gloas beacon block: %w", err) + } + finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) + } else { logger.Debug("this operator did not build the decided gloas block, skipping submission") r.markDutySucceeded() r.measurements.EndDutyFlow() - return nil } - block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) - if err != nil { - return fmt.Errorf("could not decode decided gloas block: %w", err) + r.triggerEnvelopeIfSelfBuild(ctx, block, cd.Duty.Slot) + return finishErr +} + +// triggerEnvelopeIfSelfBuild starts the §6 envelope-signing duty for the slot when the decided block is +// self-build — only then does the SSV cluster sign the envelope (external builders sign their own). A +// no-op until the controller wires the starter. +func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(ctx context.Context, block *gloas.BeaconBlock, slot phase0.Slot) { + if r.startEnvelopeDuty == nil { + return + } + bid := block.Body.SignedExecutionPayloadBid + if bid == nil || bid.Message == nil || bid.Message.BuilderIndex != gloas.BuilderIndexSelfBuild { + return } + r.startEnvelopeDuty(ctx, slot) +} - start := time.Now() - signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} - if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { - recordFailedSubmission(ctx, spectypes.BNRoleProposer) - return fmt.Errorf("submit gloas beacon block: %w", err) +// recordDecidedBlockRoot stores the §4-decided block's root for the §6 envelope runner and its +// value-check to read (SIP #94 §6). No-op when no envelope runner shares the store. +func (r *ProposerRunner) recordDecidedBlockRoot(slot phase0.Slot, block *gloas.BeaconBlock) error { + if r.proposedBlockRoots == nil { + return nil } - return r.finishSubmittedProposal(ctx, logger, span, start, nil) + root, err := block.HashTreeRoot() + if err != nil { + return fmt.Errorf("hash tree root of decided gloas block: %w", err) + } + r.proposedBlockRoots.Set(slot, phase0.Root(root)) + return nil } func (r *ProposerRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 52b03a32ce..932e2e79f0 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -634,3 +634,77 @@ func cloneTestNetworkConfig() *networkconfig.Network { cfg.SSV = &ssvCfg return &cfg } + +func gloasExternalBuildConsensusData(t *testing.T, slot phase0.Slot) *spectypes.ProposerConsensusData { + t.Helper() + block := gloas.TestingBeaconBlock(slot) + block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 5 // an external builder, not self-build + dataSSZ, err := block.MarshalSSZ() + require.NoError(t, err) + return &spectypes.ProposerConsensusData{ + Duty: *gloasProposerDuty(slot), + Version: networkconfig.DataVersionGloas, + DataSSZ: dataSSZ, + } +} + +// On the self-build path the proposer starts the §6 envelope-signing duty for the slot (fires on every +// operator, builder or not, so all join the envelope round). +func TestProposerRunnerSubmitGloasProposalTriggersEnvelopeOnSelfBuild(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + consensusData := gloasProposerConsensusData(t, slot) // self-build (TestingBeaconBlock) + runner, keySet, _ := newProposerRunnerForTest(t, newProposerTestBeacon(nil), &stubDoppelganger{canSign: true}, 0, nil) + setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) + runner.cachedGloasBlockSSZ = []byte("not-the-builder") // non-builder path (no publish needed) + + var gotSlot phase0.Slot + called := false + runner.startEnvelopeDuty = func(_ context.Context, s phase0.Slot) { called, gotSlot = true, s } + + err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{}) + require.NoError(t, err) + require.True(t, called, "self-build should start the envelope duty") + require.Equal(t, slot, gotSlot) +} + +// Block built by an external builder: that builder signs its own envelope, so SSV must not start one. +func TestProposerRunnerSubmitGloasProposalSkipsEnvelopeOnExternalBuild(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + consensusData := gloasExternalBuildConsensusData(t, slot) + runner, keySet, _ := newProposerRunnerForTest(t, newProposerTestBeacon(nil), &stubDoppelganger{canSign: true}, 0, nil) + setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) + runner.cachedGloasBlockSSZ = []byte("not-the-builder") + + called := false + runner.startEnvelopeDuty = func(_ context.Context, _ phase0.Slot) { called = true } + + err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{}) + require.NoError(t, err) + require.False(t, called, "external-build should not start the envelope duty") +} + +// recordDecidedBlockRoot stores exactly block.HashTreeRoot() — the root the §6 envelope value-check +// matches against — and is a no-op without a store. +func TestProposerRunnerRecordDecidedBlockRoot(t *testing.T) { + t.Parallel() + + runner, _, _ := newProposerRunnerForTest(t, newProposerTestBeacon(nil), &stubDoppelganger{canSign: true}, 0, nil) + + // No store (no envelope runner) → no-op, no error. + require.NoError(t, runner.recordDecidedBlockRoot(9, gloas.TestingBeaconBlock(9))) + + store := ssv.NewProposedBlockRoots() + runner.proposedBlockRoots = store + block := gloas.TestingBeaconBlock(8) + require.NoError(t, runner.recordDecidedBlockRoot(8, block)) + + expectedRoot, err := block.HashTreeRoot() + require.NoError(t, err) + got, ok := store.Get(8) + require.True(t, ok) + require.Equal(t, phase0.Root(expectedRoot), got) +} From b65a40b167685c0950c918f028e43f01957531c5 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 14:23:47 +0300 Subject: [PATCH 046/150] =?UTF-8?q?gloas:=20address=20=C2=A76=20envelope/p?= =?UTF-8?q?roposer=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BlindedExecutionPayloadEnvelope.BuilderIndex: use the named gloas.BuilderIndex (as the bid does; SSZ-identical). The per-file sszgen directive can't resolve a sibling-file type, so switch it to package mode (-path .) like beacon_block.go. - Note both ExecutionRequests fields track the pinned pre-EIP-8282 Gloas spec; a node-side variant follows when the target devnet adopts EIP-8282 (builder deposit/exit requests). - submitGloasProposal: the self-build builder now triggers the §6 envelope even when its own block submission fails — the envelope is a cluster round the other operators join regardless, so the builder shouldn't abstain. - startEnvelopeDuty: drop the post-consensus context it forwarded (cancelled when the block duty ends); document that the wired starter must dispatch async with a node-scoped context. --- protocol/v2/ssv/runner/proposer.go | 27 +++++++++++-------- protocol/v2/ssv/runner/proposer_test.go | 4 +-- protocol/v2/ssv/value_check.go | 2 +- protocol/v2/ssv/value_check_test.go | 10 +++---- protocol/v2/types/gloas/beacon_block.go | 4 ++- .../types/gloas/execution_payload_envelope.go | 14 ++++++---- .../execution_payload_envelope_encoding.go | 8 +++--- .../gloas/execution_payload_envelope_test.go | 4 +-- 8 files changed, 42 insertions(+), 31 deletions(-) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 02b07923cd..55c0e89fad 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -72,7 +72,9 @@ type ProposerRunner struct { // startEnvelopeDuty starts the §6 envelope-signing duty for a slot, called after a self-build §4 // block is published. Injected by the controller; nil pre-Gloas / when the envelope runner is absent. - startEnvelopeDuty func(ctx context.Context, slot phase0.Slot) + // It must dispatch async with a node-scoped context: the caller runs on the proposer's post-consensus + // path, whose context is cancelled once the block duty ends. + startEnvelopeDuty func(slot phase0.Slot) } // ProposerRunnerOptions bundles all dependencies required by NewProposerRunner. @@ -94,8 +96,8 @@ type ProposerRunnerOptions struct { ProposedBlockRoots *ssv.ProposedBlockRoots // StartEnvelopeDuty starts the §6 envelope-signing duty for a slot; called after a self-build §4 - // block is published. Optional. - StartEnvelopeDuty func(ctx context.Context, slot phase0.Slot) + // block is published. Must dispatch async with a node-scoped context (see startEnvelopeDuty). Optional. + StartEnvelopeDuty func(slot phase0.Slot) } func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { @@ -548,7 +550,9 @@ func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *za // submitGloasProposal publishes the decided Gloas (ePBS) block — only the builder (content match) // submits, the others just complete the duty — then on the self-build path starts the §6 // envelope-signing duty. The block is decoded up front because every operator needs its bid for the -// self-build check; the trigger runs after publication and async, so it never delays the block. +// self-build check. The envelope trigger fires on every operator (the envelope is a cluster round the +// others join regardless, so the builder joins even if its own submit failed) and dispatches async, so +// it never delays the block. func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) if err != nil { @@ -561,23 +565,24 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { recordFailedSubmission(ctx, spectypes.BNRoleProposer) - return fmt.Errorf("submit gloas beacon block: %w", err) + finishErr = fmt.Errorf("submit gloas beacon block: %w", err) + } else { + finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) } - finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) } else { logger.Debug("this operator did not build the decided gloas block, skipping submission") r.markDutySucceeded() r.measurements.EndDutyFlow() } - r.triggerEnvelopeIfSelfBuild(ctx, block, cd.Duty.Slot) + r.triggerEnvelopeIfSelfBuild(block, cd.Duty.Slot) return finishErr } // triggerEnvelopeIfSelfBuild starts the §6 envelope-signing duty for the slot when the decided block is -// self-build — only then does the SSV cluster sign the envelope (external builders sign their own). A -// no-op until the controller wires the starter. -func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(ctx context.Context, block *gloas.BeaconBlock, slot phase0.Slot) { +// self-build — only then does the SSV cluster sign the envelope (external builders sign their own). The +// starter dispatches async (see startEnvelopeDuty); a no-op until the controller wires it. +func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(block *gloas.BeaconBlock, slot phase0.Slot) { if r.startEnvelopeDuty == nil { return } @@ -585,7 +590,7 @@ func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(ctx context.Context, block * if bid == nil || bid.Message == nil || bid.Message.BuilderIndex != gloas.BuilderIndexSelfBuild { return } - r.startEnvelopeDuty(ctx, slot) + r.startEnvelopeDuty(slot) } // recordDecidedBlockRoot stores the §4-decided block's root for the §6 envelope runner and its diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 932e2e79f0..481a434fff 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -661,7 +661,7 @@ func TestProposerRunnerSubmitGloasProposalTriggersEnvelopeOnSelfBuild(t *testing var gotSlot phase0.Slot called := false - runner.startEnvelopeDuty = func(_ context.Context, s phase0.Slot) { called, gotSlot = true, s } + runner.startEnvelopeDuty = func(s phase0.Slot) { called, gotSlot = true, s } err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{}) require.NoError(t, err) @@ -680,7 +680,7 @@ func TestProposerRunnerSubmitGloasProposalSkipsEnvelopeOnExternalBuild(t *testin runner.cachedGloasBlockSSZ = []byte("not-the-builder") called := false - runner.startEnvelopeDuty = func(_ context.Context, _ phase0.Slot) { called = true } + runner.startEnvelopeDuty = func(_ phase0.Slot) { called = true } err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{}) require.NoError(t, err) diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index fec370eb60..86c15cdda3 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -198,7 +198,7 @@ func (v *envelopeChecker) CheckValue(value []byte) error { } // This duty applies only to the self-build path; external builders sign their own envelopes. - if blinded.BuilderIndex != uint64(gloas.BuilderIndexSelfBuild) { + if blinded.BuilderIndex != gloas.BuilderIndexSelfBuild { return spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "envelope builder index is not self-build") } diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 61423ceaeb..d8996f83fb 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -249,7 +249,7 @@ func TestProposerChecker_GloasDecodeError(t *testing.T) { var envelopeValidatorPK = phase0.BLSPubKey{0x42} -func encodeEnvelopeValue(t *testing.T, slot phase0.Slot, valIdx phase0.ValidatorIndex, pk phase0.BLSPubKey, blockRoot phase0.Root, builderIndex uint64) []byte { +func encodeEnvelopeValue(t *testing.T, slot phase0.Slot, valIdx phase0.ValidatorIndex, pk phase0.BLSPubKey, blockRoot phase0.Root, builderIndex gloas.BuilderIndex) []byte { t.Helper() blinded := &gloas.BlindedExecutionPayloadEnvelope{ PayloadRoot: phase0.Root{0x09}, @@ -284,7 +284,7 @@ func newEnvelopeCheckerWithRoot(slot phase0.Slot, root phase0.Root) ValueChecker func TestEnvelopeChecker_Valid(t *testing.T) { root := phase0.Root{0xaa} checker := newEnvelopeCheckerWithRoot(7, root) - require.NoError(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, root, uint64(gloas.BuilderIndexSelfBuild)))) + require.NoError(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, root, gloas.BuilderIndexSelfBuild))) } func TestEnvelopeChecker_NotSelfBuild(t *testing.T) { @@ -295,19 +295,19 @@ func TestEnvelopeChecker_NotSelfBuild(t *testing.T) { func TestEnvelopeChecker_WrongBlockRoot(t *testing.T) { checker := newEnvelopeCheckerWithRoot(7, phase0.Root{0xaa}) - require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xbb}, uint64(gloas.BuilderIndexSelfBuild)))) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xbb}, gloas.BuilderIndexSelfBuild))) } // The §4 root must be present — the proposer runner must have decided and recorded it. func TestEnvelopeChecker_NoDecidedRoot(t *testing.T) { checker := NewEnvelopeChecker(NewProposedBlockRoots(), 7, spectypes.ValidatorPK(envelopeValidatorPK), 3) - require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xaa}, uint64(gloas.BuilderIndexSelfBuild)))) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 7, 3, envelopeValidatorPK, phase0.Root{0xaa}, gloas.BuilderIndexSelfBuild))) } func TestEnvelopeChecker_WrongSlot(t *testing.T) { root := phase0.Root{0xaa} checker := newEnvelopeCheckerWithRoot(7, root) - require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 8, 3, envelopeValidatorPK, root, uint64(gloas.BuilderIndexSelfBuild)))) + require.Error(t, checker.CheckValue(encodeEnvelopeValue(t, 8, 3, envelopeValidatorPK, root, gloas.BuilderIndexSelfBuild))) } func TestEnvelopeChecker_DecodeError(t *testing.T) { diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go index ee6b456f92..c4a59219ac 100644 --- a/protocol/v2/types/gloas/beacon_block.go +++ b/protocol/v2/types/gloas/beacon_block.go @@ -39,7 +39,9 @@ type BeaconBlockBody struct { BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `ssz-max:"16"` SignedExecutionPayloadBid *SignedExecutionPayloadBid PayloadAttestations []*PayloadAttestation `ssz-max:"4"` - ParentExecutionRequests *electra.ExecutionRequests + // electra.ExecutionRequests matches the pinned Gloas spec (6ebb2216c); EIP-8282 (builder + // deposit/exit requests, Glamsterdam) will extend it — swap to a node-side variant then. + ParentExecutionRequests *electra.ExecutionRequests } // BeaconBlock is the Gloas (ePBS) beacon block. diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index 4ad2114668..cabf8f5ff9 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -5,9 +5,10 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ) -// Regenerate with `go generate ./...`. The includes are resolved from the module graph (`go list -m`) -// so they track go-eth2-client across dependency bumps rather than pinning. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./execution_payload_envelope.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs BlindedExecutionPayloadEnvelope" +// Regenerate with `go generate ./...`. -path is the package dir (not just this file) so sszgen resolves +// the sibling gloas BuilderIndex the envelope references; --objs limits output to the blinded envelope, +// collected into its own _encoding.go. Includes track go-eth2-client via `go list -m`. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs BlindedExecutionPayloadEnvelope --output ./execution_payload_envelope_encoding.go" // BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 // envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by @@ -16,9 +17,12 @@ import ( // SignedExecutionPayloadEnvelope. It rides in EnvelopeConsensusData.DataSSZ; blinding keeps that QBFT // value bounded — a few hundred bytes rather than the full payload's hundreds of KB to ~MB. type BlindedExecutionPayloadEnvelope struct { - PayloadRoot phase0.Root `ssz-size:"32"` + PayloadRoot phase0.Root `ssz-size:"32"` + // electra.ExecutionRequests matches the pinned Gloas spec (consensus-specs 6ebb2216c). EIP-8282 + // (builder deposit/exit requests, slated for Glamsterdam) will extend it — swap to a node-side Gloas + // variant when the target devnet adopts it. ExecutionRequests *electra.ExecutionRequests - BuilderIndex uint64 + BuilderIndex BuilderIndex BeaconBlockRoot phase0.Root `ssz-size:"32"` ParentBeaconBlockRoot phase0.Root `ssz-size:"32"` } diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go index c0dadfc9ea..39f6863132 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 6f5baa3e493219b71f0a6c2e2c7c996d65da9ea3c31ccc683b80b4565d4c3677 +// Hash: 857c3813ae35f3cb7296d79f5c30b5aea2d198fa9ab4448d4fe0f1f9ceaa82ff // Version: 0.1.3 package gloas @@ -25,7 +25,7 @@ func (b *BlindedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, dst = ssz.WriteOffset(dst, offset) // Field (2) 'BuilderIndex' - dst = ssz.MarshalUint64(dst, b.BuilderIndex) + dst = ssz.MarshalUint64(dst, uint64(b.BuilderIndex)) // Field (3) 'BeaconBlockRoot' dst = append(dst, b.BeaconBlockRoot[:]...) @@ -65,7 +65,7 @@ func (b *BlindedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { } // Field (2) 'BuilderIndex' - b.BuilderIndex = ssz.UnmarshallUint64(buf[36:44]) + b.BuilderIndex = BuilderIndex(ssz.UnmarshallUint64(buf[36:44])) // Field (3) 'BeaconBlockRoot' copy(b.BeaconBlockRoot[:], buf[44:76]) @@ -117,7 +117,7 @@ func (b *BlindedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (e } // Field (2) 'BuilderIndex' - hh.PutUint64(b.BuilderIndex) + hh.PutUint64(uint64(b.BuilderIndex)) // Field (3) 'BeaconBlockRoot' hh.PutBytes(b.BeaconBlockRoot[:]) diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go index 3061078025..560c374100 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_test.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -13,7 +13,7 @@ func TestBlindedExecutionPayloadEnvelopeRoundTrip(t *testing.T) { in := &BlindedExecutionPayloadEnvelope{ PayloadRoot: phase0.Root{0x01}, ExecutionRequests: &electra.ExecutionRequests{}, - BuilderIndex: uint64(BuilderIndexSelfBuild), + BuilderIndex: BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0x02}, ParentBeaconBlockRoot: phase0.Root{0x03}, } @@ -23,7 +23,7 @@ func TestBlindedExecutionPayloadEnvelopeRoundTrip(t *testing.T) { out := &BlindedExecutionPayloadEnvelope{} require.NoError(t, out.UnmarshalSSZ(b)) require.Equal(t, in.PayloadRoot, out.PayloadRoot) - require.Equal(t, uint64(BuilderIndexSelfBuild), out.BuilderIndex) + require.Equal(t, BuilderIndexSelfBuild, out.BuilderIndex) require.Equal(t, in.BeaconBlockRoot, out.BeaconBlockRoot) require.Equal(t, in.ParentBeaconBlockRoot, out.ParentBeaconBlockRoot) From c9cd1006bf1eeff3bd99b984f3c092cb4005579c Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 15:13:29 +0300 Subject: [PATCH 047/150] =?UTF-8?q?gloas:=20add=20the=20=C2=A76=20Envelope?= =?UTF-8?q?Builder=20runner=20and=20register=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EnvelopeBuilderRunner (RoleEnvelopeBuilder=9): a second QBFT instance for the proposer's slot that signs the blinded execution-payload envelope under DOMAIN_BEACON_BUILDER. Mirrors the proposer's QBFT/post-consensus flow with no pre-consensus; its slot-specific value-check is rebuilt per duty, as the committee runner does. Produce (fetch envelope + PayloadRoot) and publish (POST) stub pending the full Gloas execution payload. - Register it in SetupRunners, sharing the proposer's proposedBlockRoots. The trigger that starts the duty (and §6 message validation) lands with the heavy payload. - Extract signAndBroadcastPostConsensusMsg in BaseRunner; the proposer and the envelope runner now share it instead of duplicating the post-consensus broadcast. --- operator/validator/controller.go | 11 + protocol/v2/ssv/runner/envelope.go | 333 ++++++++++++++++++++++++ protocol/v2/ssv/runner/envelope_test.go | 98 +++++++ protocol/v2/ssv/runner/proposer.go | 27 +- protocol/v2/ssv/runner/runner.go | 36 +++ 5 files changed, 479 insertions(+), 26 deletions(-) create mode 100644 protocol/v2/ssv/runner/envelope.go create mode 100644 protocol/v2/ssv/runner/envelope_test.go diff --git a/operator/validator/controller.go b/operator/validator/controller.go index bc8fe67c65..f4581263d5 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1168,6 +1168,7 @@ func SetupRunners( runnersType := []spectypes.RunnerRole{ spectypes.RoleProposer, + spectypes.RoleEnvelopeBuilder, ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution, spectypes.RoleValidatorRegistration, @@ -1227,6 +1228,16 @@ func SetupRunners( ProposerDelay: options.ProposerDelay, ProposedBlockRoots: proposedBlockRoots, }) + case spectypes.RoleEnvelopeBuilder: + // The §6 envelope runner shares the proposer's proposedBlockRoots (it reads the §4 root the + // proposer records). Its value-check is built per duty, so none is passed here. The proposer + // trigger that starts this duty is wired with the heavy execution-payload piece. + runners[role], err = runner.NewEnvelopeBuilderRunner(runner.EnvelopeBuilderRunnerOptions{ + BaseRunnerOptions: baseOpts, + QBFTController: buildController(spectypes.RoleEnvelopeBuilder), + ProposedBlockRoots: proposedBlockRoots, + HighestDecidedSlot: 0, + }) case ssvtypes.RoleAggregator: // Post-Boole, aggregator duties route through the merged AggregatorCommitteeRunner // (committee-scoped) instead of this legacy per-validator runner. diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go new file mode 100644 index 0000000000..73e5cb9042 --- /dev/null +++ b/protocol/v2/ssv/runner/envelope.go @@ -0,0 +1,333 @@ +package runner + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + spectypes "github.com/ssvlabs/ssv-spec/types" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/ssvsigner/ekm" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" + "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" + "github.com/ssvlabs/ssv/protocol/v2/ssv" + ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// EnvelopeBuilderRunner runs the §6 execution-payload-envelope-signing duty (SIP #94 §6, +// RoleEnvelopeBuilder=9). It is a second QBFT instance for the proposer's slot, started by the proposer +// only on the self-build path (external builders sign their own envelopes). The flow mirrors the proposer +// minus pre-consensus: executeDuty produces a BlindedExecutionPayloadEnvelope and runs QBFT over it; +// ProcessConsensus signs the decided blinded root under DOMAIN_BEACON_BUILDER and broadcasts a +// post-consensus partial signature; ProcessPostConsensus reconstructs the BLS signature and the builder +// publishes the full envelope. +// +// Produce (fetch the envelope + compute PayloadRoot) and publish (POST the full envelope) are stubbed +// pending the full Gloas execution payload + goclient endpoints; the QBFT / signing / value-check flow is +// complete. +type EnvelopeBuilderRunner struct { + *BaseRunner + + beacon beacon.BeaconNode + network protocolp2p.Network + signer ekm.BeaconSigner + operatorSigner ssvtypes.OperatorSigner + measurements *dutyMeasurements + + // ValCheck validates the QBFT value (the blinded envelope). It is slot-specific (it matches the §4 + // root recorded for the duty's slot), so it is rebuilt per duty in executeDuty — as the committee + // runner rebuilds its vote check — rather than fixed at construction. + ValCheck ssv.ValueChecker + + // proposedBlockRoots gives executeDuty the §4-decided block root for the slot (the envelope's + // BeaconBlockRoot), recorded by the proposer runner. Shared with ValCheck, which checks the same root. + proposedBlockRoots *ssv.ProposedBlockRoots +} + +// EnvelopeBuilderRunnerOptions bundles the dependencies required by NewEnvelopeBuilderRunner. +type EnvelopeBuilderRunnerOptions struct { + BaseRunnerOptions + + QBFTController *controller.Controller + ProposedBlockRoots *ssv.ProposedBlockRoots + HighestDecidedSlot phase0.Slot +} + +func NewEnvelopeBuilderRunner(opts EnvelopeBuilderRunnerOptions) (Runner, error) { + if len(opts.Share) != 1 { + return nil, errors.New("must have one share") + } + + return &EnvelopeBuilderRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleEnvelopeBuilder, + NetworkConfig: opts.NetworkConfig, + Share: opts.Share, + QBFTController: opts.QBFTController, + highestDecidedSlot: opts.HighestDecidedSlot, + }, + + beacon: opts.Beacon, + network: opts.Network, + signer: opts.Signer, + operatorSigner: opts.OperatorSigner, + measurements: newMeasurementsStore(), + proposedBlockRoots: opts.ProposedBlockRoots, + }, nil +} + +func (r *EnvelopeBuilderRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + return r.baseStartNewDuty(ctx, logger, r, validatorDuty, quorum) +} + +// ProcessPreConsensus is unreachable: the envelope duty has no pre-consensus phase. +func (r *EnvelopeBuilderRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return errors.New("no pre-consensus phase for envelope builder") +} + +func (r *EnvelopeBuilderRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { + // Reuse the existing span instead of generating a new one to keep tracing-data lightweight. + span := trace.SpanFromContext(ctx) + + decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, signedMsg, &gloas.EnvelopeConsensusData{}) + if err != nil { + return fmt.Errorf("failed processing consensus message: %w", err) + } + // Decided returns true only once, so it is for the current running instance. + if !decided { + return nil + } + + r.measurements.EndConsensus() + recordConsensusDuration(ctx, r.measurements.ConsensusTime(), spectypes.RoleEnvelopeBuilder) + + cd := decidedValue.(*gloas.EnvelopeConsensusData) + + blinded := &gloas.BlindedExecutionPayloadEnvelope{} + if err := blinded.Decode(cd.DataSSZ); err != nil { + return fmt.Errorf("could not decode blinded envelope from consensus data: %w", err) + } + + duty, err := r.currentValidatorDuty() + if err != nil { + return fmt.Errorf("current validator duty: %w", err) + } + + // The blinded envelope's root equals the full envelope's, so this signature is valid for the full + // SignedExecutionPayloadEnvelope. Signed under DOMAIN_BEACON_BUILDER (not DOMAIN_PROPOSER). + span.AddEvent("signing blinded envelope") + msg, err := signBeaconObject(ctx, r, r.NetworkConfig, duty, blinded, cd.Duty.Slot, spectypes.DomainBeaconBuilder) + if err != nil { + return fmt.Errorf("failed signing blinded envelope: %w", err) + } + + postConsensusMsg := &spectypes.PartialSignatureMessages{ + Type: spectypes.PostConsensusPartialSig, + Slot: cd.Duty.Slot, + Messages: []*spectypes.PartialSignatureMessage{msg}, + } + + r.measurements.StartPostConsensus() + span.AddEvent("broadcasting post-consensus partial signature message") + if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey[:], postConsensusMsg); err != nil { + return fmt.Errorf("can't broadcast partial post-consensus sig: %w", err) + } + + return nil +} + +func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { + // Reuse the existing span instead of generating a new one to keep tracing-data lightweight. + span := trace.SpanFromContext(ctx) + + hasQuorum, roots, err := r.basePostConsensusMsgProcessing(ctx, logger, r, signedMsg) + if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { + err = NewRetryableError(err) + } + if err != nil { + return fmt.Errorf("failed processing post-consensus message: %w", err) + } + if !hasQuorum { + return nil + } + + // We have quorum and are committed to completing the duty here; the quorum fires only once, so a + // terminal failure below won't be retried. + defer func() { + if err != nil { + r.markDutyFailed(err) + } + }() + + r.measurements.EndPostConsensus() + recordPostConsensusDuration(ctx, r.measurements.PostConsensusTime(), spectypes.RoleEnvelopeBuilder) + + // only 1 root, verified by expectedPostConsensusRootsAndDomain + root := roots[0] + + sig, err := r.State.ReconstructBeaconSig(r.State.PostConsensusContainer, root, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) + if err != nil { + // If the reconstructed signature verification failed, fall back to verifying each partial signature. + r.FallBackAndVerifyEachSignature(r.State.PostConsensusContainer, root, r.GetShare().Committee, r.GetShare().ValidatorIndex) + return fmt.Errorf("got post-consensus quorum but it has invalid signatures: %w", err) + } + specSig := phase0.BLSSignature{} + copy(specSig[:], sig) + + cd := &gloas.EnvelopeConsensusData{} + if err := cd.Decode(r.State.DecidedValue); err != nil { + return fmt.Errorf("could not decode decided envelope consensus data: %w", err) + } + + span.AddEvent("submitting execution payload envelope") + return r.submitEnvelope(ctx, logger, cd, specSig) +} + +// submitEnvelope publishes the signed execution-payload envelope. Only the operator that built the decided +// envelope (content match) publishes; the others just complete the duty. +// +// STUB: reconstructing and POSTing the full SignedExecutionPayloadEnvelope needs the full Gloas execution +// payload (EIP-7928 block_access_list, EIP-7843 slot_number) and the goclient submit endpoint. +func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { + return errors.New("gloas envelope publication not yet implemented (needs the full Gloas execution payload)") +} + +func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { + r.measurements.StartDutyFlow() + + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + slot := validatorDuty.DutySlot() + + // The §6 value-check is slot-specific (it matches the §4 root recorded for this slot), so rebuild it + // per duty — as the committee runner does for its vote check — before starting QBFT. + share := r.GetShare() + r.ValCheck = ssv.NewEnvelopeChecker(r.proposedBlockRoots, slot, share.ValidatorPubKey, share.ValidatorIndex) + + // The envelope commits to the §4-decided block, so the proposer must have decided and recorded its root. + beaconBlockRoot, ok := r.proposedBlockRoots.Get(slot) + if !ok { + return fmt.Errorf("no decided block root recorded for envelope slot %d", slot) + } + + input, err := r.produceBlindedEnvelope(ctx, validatorDuty, beaconBlockRoot) + if err != nil { + return fmt.Errorf("produce blinded envelope: %w", err) + } + + r.measurements.StartConsensus() + if err := r.decide(ctx, logger, slot, input, r.ValCheck); err != nil { + return fmt.Errorf("qbft-decide: %w", err) + } + return nil +} + +// produceBlindedEnvelope fetches this operator's execution-payload envelope for the slot and wraps its +// blinded form as the QBFT value. +// +// STUB: needs the full Gloas execution payload to compute PayloadRoot = hash_tree_root(payload) and the +// goclient GET getExecutionPayloadEnvelope(slot, beacon_block_root). +func (r *EnvelopeBuilderRunner) produceBlindedEnvelope(ctx context.Context, duty *spectypes.ValidatorDuty, beaconBlockRoot phase0.Root) (*gloas.EnvelopeConsensusData, error) { + return nil, errors.New("gloas envelope production not yet implemented (needs the full Gloas execution payload)") +} + +// expectedPreConsensusRootsAndDomain is unreachable: the envelope duty has no pre-consensus phase. +func (r *EnvelopeBuilderRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, phase0.DomainType{}, errors.New("no pre-consensus phase for envelope builder") +} + +func (r *EnvelopeBuilderRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { + cd := &gloas.EnvelopeConsensusData{} + if err := cd.Decode(r.State.DecidedValue); err != nil { + return nil, phase0.DomainType{}, fmt.Errorf("could not decode envelope consensus data: %w", err) + } + blinded := &gloas.BlindedExecutionPayloadEnvelope{} + if err := blinded.Decode(cd.DataSSZ); err != nil { + return nil, phase0.DomainType{}, fmt.Errorf("could not decode blinded envelope: %w", err) + } + return []ssz.HashRoot{blinded}, spectypes.DomainBeaconBuilder, nil +} + +func (r *EnvelopeBuilderRunner) GetNetwork() protocolp2p.Network { + return r.network +} + +func (r *EnvelopeBuilderRunner) GetBeaconNode() beacon.BeaconNode { + return r.beacon +} + +func (r *EnvelopeBuilderRunner) GetShare() *spectypes.Share { + for _, share := range r.Share { + return share + } + return nil +} + +func (r *EnvelopeBuilderRunner) GetSigner() ekm.BeaconSigner { + return r.signer +} + +func (r *EnvelopeBuilderRunner) GetOperatorSigner() ssvtypes.OperatorSigner { + return r.operatorSigner +} + +func (r *EnvelopeBuilderRunner) MarshalJSON() ([]byte, error) { + type envelopeBuilderRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + // ValCheck is a runtime-only dependency, ignored on decode; always marshaled as null. + ValCheck any `json:"ValCheck"` + } + return json.Marshal(&envelopeBuilderRunnerJSON{ + BaseRunner: r.BaseRunner, + ValCheck: nil, + }) +} + +func (r *EnvelopeBuilderRunner) UnmarshalJSON(data []byte) error { + type envelopeBuilderRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + ValCheck json.RawMessage `json:"ValCheck"` + } + aux := &envelopeBuilderRunnerJSON{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if aux.BaseRunner == nil { + return fmt.Errorf("missing BaseRunner") + } + r.BaseRunner = aux.BaseRunner + // ValCheck is not restored from JSON. Callers must rehydrate it explicitly. + r.ValCheck = nil + return nil +} + +func (r *EnvelopeBuilderRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *EnvelopeBuilderRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *EnvelopeBuilderRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + return [32]byte{}, fmt.Errorf("could not encode EnvelopeBuilderRunner: %w", err) + } + return sha256.Sum256(marshaledRoot), nil +} diff --git a/protocol/v2/ssv/runner/envelope_test.go b/protocol/v2/ssv/runner/envelope_test.go new file mode 100644 index 0000000000..6d855f394e --- /dev/null +++ b/protocol/v2/ssv/runner/envelope_test.go @@ -0,0 +1,98 @@ +package runner + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/ssv" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// envelopeConsensusDataSSZ builds a decided EnvelopeConsensusData carrying a self-build blinded envelope, +// returning the blinded value (for root comparison) and the encoded consensus data. +func envelopeConsensusDataSSZ(t *testing.T, slot phase0.Slot, blockRoot phase0.Root) (*gloas.BlindedExecutionPayloadEnvelope, []byte) { + t.Helper() + blinded := &gloas.BlindedExecutionPayloadEnvelope{ + PayloadRoot: phase0.Root{0x09}, + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: gloas.BuilderIndexSelfBuild, + BeaconBlockRoot: blockRoot, + ParentBeaconBlockRoot: phase0.Root{0x08}, + } + dataSSZ, err := blinded.Encode() + require.NoError(t, err) + cd := &gloas.EnvelopeConsensusData{ + Duty: spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: slot, ValidatorIndex: 3}, + DataSSZ: dataSSZ, + } + encoded, err := cd.Encode() + require.NoError(t, err) + return blinded, encoded +} + +func TestNewEnvelopeBuilderRunner_RequiresOneShare(t *testing.T) { + _, err := NewEnvelopeBuilderRunner(EnvelopeBuilderRunnerOptions{}) + require.Error(t, err) +} + +// The post-consensus signing target is the decided blinded envelope's root under DOMAIN_BEACON_BUILDER — +// equal to the full envelope's root, so the partial signature is valid for the full envelope. +func TestEnvelopeBuilderRunner_ExpectedPostConsensusRootsAndDomain(t *testing.T) { + blinded, encoded := envelopeConsensusDataSSZ(t, 5, phase0.Root{0xaa}) + r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{State: &State{DecidedValue: encoded}}} + + roots, domain, err := r.expectedPostConsensusRootsAndDomain(context.Background()) + require.NoError(t, err) + require.Equal(t, phase0.DomainType(spectypes.DomainBeaconBuilder), domain) + require.Len(t, roots, 1) + + got, err := roots[0].HashTreeRoot() + require.NoError(t, err) + want, err := blinded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) +} + +// The envelope duty has no pre-consensus phase; both entry points reject. +func TestEnvelopeBuilderRunner_NoPreConsensus(t *testing.T) { + r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}} + require.Error(t, r.ProcessPreConsensus(context.Background(), zap.NewNop(), &spectypes.PartialSignatureMessages{})) + _, _, err := r.expectedPreConsensusRootsAndDomain() + require.Error(t, err) +} + +// executeDuty requires the proposer to have recorded the §4 block root for the slot, then stubs the +// (heavy-payload-dependent) production. +func TestEnvelopeBuilderRunner_ExecuteDuty(t *testing.T) { + store := ssv.NewProposedBlockRoots() + r := &EnvelopeBuilderRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleEnvelopeBuilder, + Share: map[phase0.ValidatorIndex]*spectypes.Share{ + 3: {ValidatorIndex: 3, ValidatorPubKey: spectypes.ValidatorPK{0x42}}, + }, + }, + measurements: newMeasurementsStore(), + proposedBlockRoots: store, + } + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: 5, ValidatorIndex: 3} + + // No recorded §4 root → guarded. + require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "no decided block root") + + // With the root present, production is reached and stubs out pending the full Gloas payload. + store.Set(5, phase0.Root{0xaa}) + require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "not yet implemented") +} + +func TestEnvelopeBuilderRunner_SubmitEnvelopeStub(t *testing.T) { + r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}} + err := r.submitEnvelope(context.Background(), zap.NewNop(), &gloas.EnvelopeConsensusData{}, phase0.BLSSignature{}) + require.ErrorContains(t, err, "not yet implemented") +} diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 55c0e89fad..48c276ea17 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -384,34 +384,9 @@ func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logge Messages: []*spectypes.PartialSignatureMessage{msg}, } - domain := r.NetworkConfig.DomainTypeAtSlot(cd.Duty.Slot) - msgID := spectypes.NewMsgID(domain, r.GetShare().ValidatorPubKey[:], r.RunnerRoleType) - encodedMsg, err := postConsensusMsg.Encode() - if err != nil { - return fmt.Errorf("could not encode post consensus partial signature message: %w", err) - } - - ssvMsg := &spectypes.SSVMessage{ - MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: msgID, - Data: encodedMsg, - } - - span.AddEvent("signing SSV partial signature message") - sig, err := r.operatorSigner.SignSSVMessage(ssvMsg) - if err != nil { - return fmt.Errorf("could not sign SSV partial signature message: %w", err) - } - - msgToBroadcast := &spectypes.SignedSSVMessage{ - Signatures: [][]byte{sig}, - OperatorIDs: []spectypes.OperatorID{r.operatorSigner.GetOperatorID()}, - SSVMessage: ssvMsg, - } - r.measurements.StartPostConsensus() span.AddEvent("broadcasting post consensus partial signature message") - if err := r.GetNetwork().BroadcastAtSlot(msgToBroadcast, postConsensusMsg.Slot); err != nil { + if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey[:], postConsensusMsg); err != nil { return fmt.Errorf("can't broadcast partial post consensus sig: %w", err) } const broadcastedPostConsensusMsgEvent = "broadcasted post-consensus partial signature message" diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index 85fc2fc9e2..99ca63fc7a 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -386,6 +386,42 @@ func (b *BaseRunner) signAndBroadcastPartialSigMsgs( return nil } +// signAndBroadcastPostConsensusMsg signs a post-consensus partial-signature message as the operator and +// broadcasts it on its slot's subnet. Unlike signAndBroadcastPartialSigMsgs (pre-consensus), it keys the +// message id by the slot's fork domain and uses BroadcastAtSlot. +func (b *BaseRunner) signAndBroadcastPostConsensusMsg( + network protocolp2p.Network, + opSigner ssvtypes.OperatorSigner, + validatorPubKey []byte, + msgs *spectypes.PartialSignatureMessages, +) error { + domain := b.NetworkConfig.DomainTypeAtSlot(msgs.Slot) + msgID := spectypes.NewMsgID(domain, validatorPubKey, b.RunnerRoleType) + encodedMsg, err := msgs.Encode() + if err != nil { + return fmt.Errorf("could not encode post-consensus partial signature message: %w", err) + } + + ssvMsg := &spectypes.SSVMessage{ + MsgType: spectypes.SSVPartialSignatureMsgType, + MsgID: msgID, + Data: encodedMsg, + } + + sig, err := opSigner.SignSSVMessage(ssvMsg) + if err != nil { + return fmt.Errorf("could not sign post-consensus SSV message: %w", err) + } + + signed := &spectypes.SignedSSVMessage{ + Signatures: [][]byte{sig}, + OperatorIDs: []spectypes.OperatorID{opSigner.GetOperatorID()}, + SSVMessage: ssvMsg, + } + + return network.BroadcastAtSlot(signed, msgs.Slot) +} + // basePreConsensusMsgProcessing is a base func that all runner implementation can call for processing a pre-consensus msg func (b *BaseRunner) basePreConsensusMsgProcessing(ctx context.Context, logger *zap.Logger, runner Runner, signedMsg *spectypes.PartialSignatureMessages) (bool, [][32]byte, error) { // Reuse the existing span instead of generating new one to keep tracing-data lightweight. From 84ae18e15295fecd7d786942f32c2f685650d017 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 15:27:13 +0300 Subject: [PATCH 048/150] runner: dedup the {BaseRunner, ValCheck} JSON (de)serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four runners — proposer, envelope, aggregator, sync-committee — marshaled the same {BaseRunner, ValCheck:null} shape with identical boilerplate. Extract marshalRunnerStateJSON / unmarshalRunnerStateJSON and have all four call them. Output is byte-identical, so the spec-pinned runner-state roots are unchanged (verified by the spectest suite); ValCheck stays null to preserve that shape. --- protocol/v2/ssv/runner/aggregator.go | 30 +++---------------- protocol/v2/ssv/runner/envelope.go | 24 +++------------ protocol/v2/ssv/runner/proposer.go | 30 +++---------------- protocol/v2/ssv/runner/runner.go | 26 ++++++++++++++++ .../ssv/runner/sync_committee_contribution.go | 30 +++---------------- 5 files changed, 42 insertions(+), 98 deletions(-) diff --git a/protocol/v2/ssv/runner/aggregator.go b/protocol/v2/ssv/runner/aggregator.go index 7b7a966986..85c2122215 100644 --- a/protocol/v2/ssv/runner/aggregator.go +++ b/protocol/v2/ssv/runner/aggregator.go @@ -490,37 +490,15 @@ func (r *AggregatorRunner) GetOperatorSigner() ssvtypes.OperatorSigner { } func (r *AggregatorRunner) MarshalJSON() ([]byte, error) { - type aggregatorRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - // ValCheck is intentionally kept in the JSON to preserve the historical runner state shape - // (and thus runner state roots used by spec tests). It is a runtime-only dependency and - // is ignored on decode, so it is always marshaled as `null` for determinism. - ValCheck any `json:"ValCheck"` - } - - return json.Marshal(&aggregatorRunnerJSON{ - BaseRunner: r.BaseRunner, - ValCheck: nil, - }) + return marshalRunnerStateJSON(r.BaseRunner) } func (r *AggregatorRunner) UnmarshalJSON(data []byte) error { - type aggregatorRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - ValCheck json.RawMessage `json:"ValCheck"` - } - - aux := &aggregatorRunnerJSON{} - if err := json.Unmarshal(data, aux); err != nil { + br, err := unmarshalRunnerStateJSON(data) + if err != nil { return err } - - if aux.BaseRunner == nil { - return fmt.Errorf("missing BaseRunner") - } - - r.BaseRunner = aux.BaseRunner - // ValCheck is not restored from JSON. Callers must rehydrate it explicitly. + r.BaseRunner = br r.ValCheck = nil return nil } diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 73e5cb9042..f581dc45d3 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -287,31 +287,15 @@ func (r *EnvelopeBuilderRunner) GetOperatorSigner() ssvtypes.OperatorSigner { } func (r *EnvelopeBuilderRunner) MarshalJSON() ([]byte, error) { - type envelopeBuilderRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - // ValCheck is a runtime-only dependency, ignored on decode; always marshaled as null. - ValCheck any `json:"ValCheck"` - } - return json.Marshal(&envelopeBuilderRunnerJSON{ - BaseRunner: r.BaseRunner, - ValCheck: nil, - }) + return marshalRunnerStateJSON(r.BaseRunner) } func (r *EnvelopeBuilderRunner) UnmarshalJSON(data []byte) error { - type envelopeBuilderRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - ValCheck json.RawMessage `json:"ValCheck"` - } - aux := &envelopeBuilderRunnerJSON{} - if err := json.Unmarshal(data, aux); err != nil { + br, err := unmarshalRunnerStateJSON(data) + if err != nil { return err } - if aux.BaseRunner == nil { - return fmt.Errorf("missing BaseRunner") - } - r.BaseRunner = aux.BaseRunner - // ValCheck is not restored from JSON. Callers must rehydrate it explicitly. + r.BaseRunner = br r.ValCheck = nil return nil } diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 48c276ea17..5e166d49b3 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -708,37 +708,15 @@ func (r *ProposerRunner) GetOperatorSigner() ssvtypes.OperatorSigner { } func (r *ProposerRunner) MarshalJSON() ([]byte, error) { - type proposerRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - // ValCheck is intentionally kept in the JSON to preserve the historical runner state shape - // (and thus runner state roots used by spec tests). It is a runtime-only dependency and - // is ignored on decode, so it is always marshaled as `null` for determinism. - ValCheck any `json:"ValCheck"` - } - - return json.Marshal(&proposerRunnerJSON{ - BaseRunner: r.BaseRunner, - ValCheck: nil, - }) + return marshalRunnerStateJSON(r.BaseRunner) } func (r *ProposerRunner) UnmarshalJSON(data []byte) error { - type proposerRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - ValCheck json.RawMessage `json:"ValCheck"` - } - - aux := &proposerRunnerJSON{} - if err := json.Unmarshal(data, aux); err != nil { + br, err := unmarshalRunnerStateJSON(data) + if err != nil { return err } - - if aux.BaseRunner == nil { - return fmt.Errorf("missing BaseRunner") - } - - r.BaseRunner = aux.BaseRunner - // ValCheck is not restored from JSON. Callers must rehydrate it explicitly. + r.BaseRunner = br r.ValCheck = nil return nil } diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index 99ca63fc7a..4edc1436e7 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -239,6 +239,32 @@ func (b *BaseRunner) MarshalJSON() ([]byte, error) { return byts, err } +// marshalRunnerStateJSON encodes a runner whose persisted state is just its BaseRunner. ValCheck is a +// runtime-only dependency but is kept in the JSON as null to preserve the historical runner-state shape +// (and thus the state roots spec tests pin); runners restore it via unmarshalRunnerStateJSON. +func marshalRunnerStateJSON(b *BaseRunner) ([]byte, error) { + return json.Marshal(&struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + ValCheck any `json:"ValCheck"` + }{BaseRunner: b}) +} + +// unmarshalRunnerStateJSON restores the BaseRunner written by marshalRunnerStateJSON; ValCheck is left +// nil for the caller to rehydrate. +func unmarshalRunnerStateJSON(data []byte) (*BaseRunner, error) { + aux := &struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + ValCheck json.RawMessage `json:"ValCheck"` + }{} + if err := json.Unmarshal(data, aux); err != nil { + return nil, err + } + if aux.BaseRunner == nil { + return nil, fmt.Errorf("missing BaseRunner") + } + return aux.BaseRunner, nil +} + // baseStartNewDuty is a base func that all runner implementation can call to start a duty func (b *BaseRunner) baseStartNewDuty(ctx context.Context, logger *zap.Logger, runner Runner, duty spectypes.Duty, quorum uint64) error { if err := b.ShouldProcessDuty(duty); err != nil { diff --git a/protocol/v2/ssv/runner/sync_committee_contribution.go b/protocol/v2/ssv/runner/sync_committee_contribution.go index 050d8c0798..85e39c51ff 100644 --- a/protocol/v2/ssv/runner/sync_committee_contribution.go +++ b/protocol/v2/ssv/runner/sync_committee_contribution.go @@ -611,37 +611,15 @@ func (r *SyncCommitteeAggregatorRunner) GetOperatorSigner() ssvtypes.OperatorSig } func (r *SyncCommitteeAggregatorRunner) MarshalJSON() ([]byte, error) { - type syncCommitteeAggregatorRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - // ValCheck is intentionally kept in the JSON to preserve the historical runner state shape - // (and thus runner state roots used by spec tests). It is a runtime-only dependency and - // is ignored on decode, so it is always marshaled as `null` for determinism. - ValCheck any `json:"ValCheck"` - } - - return json.Marshal(&syncCommitteeAggregatorRunnerJSON{ - BaseRunner: r.BaseRunner, - ValCheck: nil, - }) + return marshalRunnerStateJSON(r.BaseRunner) } func (r *SyncCommitteeAggregatorRunner) UnmarshalJSON(data []byte) error { - type syncCommitteeAggregatorRunnerJSON struct { - BaseRunner *BaseRunner `json:"BaseRunner"` - ValCheck json.RawMessage `json:"ValCheck"` - } - - aux := &syncCommitteeAggregatorRunnerJSON{} - if err := json.Unmarshal(data, aux); err != nil { + br, err := unmarshalRunnerStateJSON(data) + if err != nil { return err } - - if aux.BaseRunner == nil { - return fmt.Errorf("missing BaseRunner") - } - - r.BaseRunner = aux.BaseRunner - // ValCheck is not restored from JSON. Callers must rehydrate it explicitly. + r.BaseRunner = br r.ValCheck = nil return nil } From 3ae04880d055467b203d9cf7ed69ee415cf2e162 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 15:41:28 +0300 Subject: [PATCH 049/150] =?UTF-8?q?message/validation:=20accept=20the=20?= =?UTF-8?q?=C2=A76=20envelope-builder=20role=20(role=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RoleEnvelopeBuilder to the validation rules, mirroring the proposer where the envelope behaves the same — QBFT consensus, post-consensus-only partial sig, a tight max round of 2, and instance-relative round timing (so it skips the slot-relative round-spread check). It is a Gloas-only role, valid from the fork; its per-epoch duty limit is SlotsPerEpoch (at most one self-build envelope per proposal slot). Not a committee role and not beacon-scheduled, so committeeRole and validateBeaconDuty are unchanged. --- message/validation/common_checks.go | 7 +-- message/validation/consensus_validation.go | 11 ++-- message/validation/envelope_builder_test.go | 57 +++++++++++++++++++++ message/validation/partial_validation.go | 3 ++ message/validation/signed_ssv_message.go | 2 +- 5 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 message/validation/envelope_builder_test.go diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 6c42519282..06c76ae47f 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -61,7 +61,7 @@ func (mv *messageValidator) earlySlotAllowance(role spectypes.RunnerRole) time.D func (mv *messageValidator) messageLateness(slot phase0.Slot, role spectypes.RunnerRole, receivedAt time.Time) time.Duration { var ttl uint64 switch role { - case spectypes.RoleProposer, ssvtypes.RoleSyncCommitteeContribution: + case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder, ssvtypes.RoleSyncCommitteeContribution: ttl = 1 + LateSlotAllowance case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: ttl = mv.maxStoredSlots() @@ -150,8 +150,9 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true - case spectypes.RoleProposerPreferences: - // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences per epoch. + case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: + // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences (and likewise + // self-build envelopes) per epoch. return mv.netCfg.SlotsPerEpoch, true default: diff --git a/message/validation/consensus_validation.go b/message/validation/consensus_validation.go index 3c845f8f9d..20efbbe94f 100644 --- a/message/validation/consensus_validation.go +++ b/message/validation/consensus_validation.go @@ -430,7 +430,7 @@ func (mv *messageValidator) maxRound(role spectypes.RunnerRole) (specqbft.Round, switch role { case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: // TODO: check if value for aggregator is correct as there are messages on stage exceeding the limit return 12, nil // TODO: consider calculating based on quick timeout and slow timeout - case spectypes.RoleProposer: + case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder: return 2, nil case ssvtypes.RoleSyncCommitteeContribution: return 6, nil @@ -537,10 +537,11 @@ func (mv *messageValidator) roundBelongsToAllowedSpread( ) error { role := signedSSVMessage.SSVMessage.GetID().GetRoleType() - // Proposer round timeouts are relative to QBFT instance start times rather than absolute time-into-slot values - // (until https://github.com/ssvlabs/ssv/issues/2429 is implemented), since we don't have any visibility into - // the actual QBFT instance state here - we can't really check whether message round belongs to allowed spread. - if role == spectypes.RoleProposer { + // Proposer and envelope-builder round timeouts are relative to QBFT instance start times rather than + // absolute time-into-slot values (until https://github.com/ssvlabs/ssv/issues/2429 is implemented), + // since we don't have visibility into the actual QBFT instance state here - we can't check whether the + // message round belongs to the allowed spread. + if role == spectypes.RoleProposer || role == spectypes.RoleEnvelopeBuilder { return nil } diff --git a/message/validation/envelope_builder_test.go b/message/validation/envelope_builder_test.go new file mode 100644 index 0000000000..08daac5b1c --- /dev/null +++ b/message/validation/envelope_builder_test.go @@ -0,0 +1,57 @@ +package validation + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + specqbft "github.com/ssvlabs/ssv-spec/qbft" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/networkconfig" +) + +// The §6 envelope duty is QBFT with only a post-consensus partial signature (no pre-consensus phase). +func TestPartialSignatureTypeMatchesRole_EnvelopeBuilder(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.partialSignatureTypeMatchesRole(spectypes.PostConsensusPartialSig, spectypes.RoleEnvelopeBuilder)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.RandaoPartialSig, spectypes.RoleEnvelopeBuilder)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.ProposerPreferencesPartialSig, spectypes.RoleEnvelopeBuilder)) +} + +// The envelope role exists only from the Gloas fork onward. +func TestValidRoleAtSlot_EnvelopeBuilderGloasOnly(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + mv := &messageValidator{netCfg: netCfg} + + preGloasSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) + gloasSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) + + require.False(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeBuilder, preGloasSlot)) + require.True(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeBuilder, gloasSlot)) +} + +// The envelope is a QBFT role (it has a max round) and shares the proposer's tight bound. +func TestMaxRound_EnvelopeBuilder(t *testing.T) { + mv := &messageValidator{} + round, err := mv.maxRound(spectypes.RoleEnvelopeBuilder) + require.NoError(t, err) + require.Equal(t, specqbft.Round(2), round) +} + +// At most one self-build proposal (hence one envelope) per slot → at most SlotsPerEpoch per epoch. +func TestDutyLimit_EnvelopeBuilder(t *testing.T) { + mv := &messageValidator{netCfg: networkconfig.TestNetwork} + msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeBuilder) + + limit, ok := mv.dutyLimit(msgID, 0, nil) + require.True(t, ok) + require.Equal(t, mv.netCfg.SlotsPerEpoch, limit) +} + +// The envelope signer advances one slot at a time, so a message for a slot below its max is stale. +func TestMonotonicSlotRole_EnvelopeBuilder(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.monotonicSlotRole(spectypes.RoleEnvelopeBuilder)) +} diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 0559e19b90..e15074daef 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -377,6 +377,9 @@ func (mv *messageValidator) partialSignatureTypeMatchesRole(msgType spectypes.Pa return msgType == spectypes.PostConsensusPartialSig || msgType == ssvtypes.SelectionProofPartialSig case spectypes.RoleProposer: return msgType == spectypes.PostConsensusPartialSig || msgType == spectypes.RandaoPartialSig + case spectypes.RoleEnvelopeBuilder: + // The §6 envelope duty has no pre-consensus phase, so only post-consensus partial sigs. + return msgType == spectypes.PostConsensusPartialSig case ssvtypes.RoleSyncCommitteeContribution: return msgType == spectypes.PostConsensusPartialSig || msgType == ssvtypes.ContributionProofs case spectypes.RoleValidatorRegistration: diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index 558f91a72d..fcf8b3fba6 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -164,7 +164,7 @@ func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot return isInBooleFork case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution: return !isInBooleFork - case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences: + case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: return isInGloas default: return false From f0d0c6549a4f6bf444e747162d9761c992742771 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 17:35:32 +0300 Subject: [PATCH 050/150] =?UTF-8?q?gloas:=20implement=20the=20=C2=A76=20he?= =?UTF-8?q?avy=20payload=20=E2=80=94=20types,=20goclient,=20runner,=20trig?= =?UTF-8?q?ger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the §6 envelope runner functional end-to-end: - Types: the full Gloas ExecutionPayload (Deneb's + block_access_list [EIP-7928, an opaque RLP byte list the CL only hashes] + slot_number [EIP-7843]), the full ExecutionPayloadEnvelope and SignedExecutionPayloadEnvelope, and a Blinded() transform. A test confirms the full envelope hashes to the same root as its blinded form (the property the §6 signing relies on). - goclient: GloasEnvelopeCalls (Get/SubmitExecutionPayloadEnvelope), hand-rolled SSZ over the unmerged beacon-APIs#580 endpoints, mirroring the §4 block calls. - runner: produceBlindedEnvelope (fetch → cache → blind → wrap) and submitEnvelope (content-match the cached envelope → the holder publishes), replacing the stubs. - trigger: the proposer's StartEnvelopeDuty now starts the role-9 duty via c.ExecuteDuty (async, validator-scoped ctx) after a self-build §4 block. - Rename gloasBlockHTTP → gloasOctetStreamHTTP (now shared by block + envelope paths). The field order and BlockAccessList ssz-max bound still need an HTR-parity check against canonical spec vectors, and the end-to-end QBFT test is a follow-up. --- beacon/goclient/gloas_envelope.go | 60 +++ beacon/goclient/gloas_envelope_test.go | 71 +++ beacon/goclient/gloas_proposer.go | 8 +- beacon/goclient/gloas_proposer_test.go | 4 +- operator/validator/controller.go | 16 +- operator/validator/controller_test.go | 5 +- protocol/v2/blockchain/beacon/client.go | 12 + protocol/v2/blockchain/beacon/mock_client.go | 82 ++++ protocol/v2/ssv/runner/envelope.go | 74 ++- protocol/v2/ssv/runner/envelope_test.go | 76 +++- protocol/v2/types/gloas/execution_payload.go | 41 ++ .../types/gloas/execution_payload_encoding.go | 426 ++++++++++++++++++ .../types/gloas/execution_payload_envelope.go | 47 +- .../execution_payload_envelope_encoding.go | 257 ++++++++++- .../gloas/execution_payload_envelope_test.go | 84 ++++ 15 files changed, 1227 insertions(+), 36 deletions(-) create mode 100644 beacon/goclient/gloas_envelope.go create mode 100644 beacon/goclient/gloas_envelope_test.go create mode 100644 protocol/v2/types/gloas/execution_payload.go create mode 100644 protocol/v2/types/gloas/execution_payload_encoding.go diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go new file mode 100644 index 0000000000..d9b6546a60 --- /dev/null +++ b/beacon/goclient/gloas_envelope.go @@ -0,0 +1,60 @@ +package goclient + +import ( + "context" + "encoding/hex" + "fmt" + "net/http" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// Gloas §6 envelope produce/publish endpoints (beacon-APIs#580, unmerged). Best-effort paths, as with +// the §4 block endpoints — verify against a real Gloas devnet BN. The publish body is the bare signed +// envelope (stateful path); the blob-carrying Contents body is deferred. +const ( + gloasProduceEnvelopePath = "/eth/v1/validator/execution_payload_envelope/%d?beacon_block_root=%s" // slot, root 0x-hex + gloasPublishEnvelopePath = "/eth/v1/beacon/execution_payload_envelope" +) + +// GetExecutionPayloadEnvelope fetches the §6 execution-payload envelope (the payload the proposer +// committed to for the slot) as SSZ — go-eth2-client has no Gloas types. +func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + return firstClientResult(ctx, gc, "GetExecutionPayloadEnvelope", http.MethodGet, func(ctx context.Context, addr string) (*gloas.ExecutionPayloadEnvelope, error) { + return requestExecutionPayloadEnvelope(ctx, addr, slot, beaconBlockRoot) + }) +} + +// SubmitExecutionPayloadEnvelope publishes a signed §6 envelope as SSZ. +func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { + body, err := signed.MarshalSSZ() + if err != nil { + return fmt.Errorf("marshal signed execution payload envelope: %w", err) + } + _, err = firstClientResult(ctx, gc, "SubmitExecutionPayloadEnvelope", http.MethodPost, func(ctx context.Context, addr string) (struct{}, error) { + return struct{}{}, submitExecutionPayloadEnvelope(ctx, addr, body) + }) + return err +} + +// requestExecutionPayloadEnvelope GETs the produce endpoint and decodes the SSZ response into an envelope. +func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + url := addr + fmt.Sprintf(gloasProduceEnvelopePath, slot, "0x"+hex.EncodeToString(beaconBlockRoot[:])) + body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + envelope := &gloas.ExecutionPayloadEnvelope{} + if err := envelope.UnmarshalSSZ(body); err != nil { + return nil, fmt.Errorf("decode execution payload envelope: %w", err) + } + return envelope, nil +} + +// submitExecutionPayloadEnvelope POSTs an SSZ-marshaled signed envelope to the publish endpoint. +func submitExecutionPayloadEnvelope(ctx context.Context, addr string, envelopeSSZ []byte) error { + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ) + return err +} diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go new file mode 100644 index 0000000000..f07cc3c91b --- /dev/null +++ b/beacon/goclient/gloas_envelope_test.go @@ -0,0 +1,71 @@ +package goclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// GoClient must satisfy the Gloas §6 envelope beacon-node surface. +var _ beacon.GloasEnvelopeCalls = (*GoClient)(nil) + +func minimalExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{}, + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: gloas.BuilderIndexSelfBuild, + } +} + +func TestRequestExecutionPayloadEnvelope(t *testing.T) { + envelopeSSZ, err := minimalExecutionPayloadEnvelope().MarshalSSZ() + require.NoError(t, err) + + var gotMethod, gotPath, gotRoot, gotAccept string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotRoot = r.URL.Query().Get("beacon_block_root") + gotAccept = r.Header.Get("Accept") + _, _ = w.Write(envelopeSSZ) + })) + defer srv.Close() + + got, err := requestExecutionPayloadEnvelope(context.Background(), srv.URL, 9, phase0.Root{0xab}) + require.NoError(t, err) + require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, "/eth/v1/validator/execution_payload_envelope/9", gotPath) + require.Equal(t, "0xab"+strings.Repeat("0", 62), gotRoot) // 32-byte root, 0x-hex + require.Equal(t, "application/octet-stream", gotAccept) + require.Equal(t, gloas.BuilderIndexSelfBuild, got.BuilderIndex) +} + +func TestSubmitExecutionPayloadEnvelope(t *testing.T) { + var gotMethod, gotPath, gotVersion, gotContentType string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotVersion = r.Header.Get("Eth-Consensus-Version") + gotContentType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := submitExecutionPayloadEnvelope(context.Background(), srv.URL, []byte{0x01, 0x02}) + require.NoError(t, err) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelope", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + require.Equal(t, "application/octet-stream", gotContentType) + require.Equal(t, []byte{0x01, 0x02}, gotBody) +} diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 0927e7ff7b..c7fec2ebb4 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -46,7 +46,7 @@ func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.Sig // requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(graffiti)) - body, err := gloasBlockHTTP(ctx, http.MethodGet, url, nil) + body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -59,14 +59,14 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, // submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { - _, err := gloasBlockHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ) + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ) return err } -// gloasBlockHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns +// gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns // the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas // consensus version. -func gloasBlockHTTP(ctx context.Context, method, url string, body []byte) ([]byte, error) { +func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte) ([]byte, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 49157a664f..addbf8ed19 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -77,13 +77,13 @@ func TestSubmitGloasBeaconBlock(t *testing.T) { require.Equal(t, []byte{0x01, 0x02}, gotBody) } -func TestGloasBlockHTTP_Non2xxIsError(t *testing.T) { +func TestGloasOctetStreamHTTP_Non2xxIsError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte("bad block")) })) defer srv.Close() - _, err := gloasBlockHTTP(context.Background(), http.MethodGet, srv.URL, nil) + _, err := gloasOctetStreamHTTP(context.Background(), http.MethodGet, srv.URL, nil) require.ErrorContains(t, err, "status 400") } diff --git a/operator/validator/controller.go b/operator/validator/controller.go index f4581263d5..9dc32f74d1 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -790,7 +790,19 @@ func (c *Controller) onShareInit(share *ssvtypes.SSVShare) (v *validator.Validat // so that when the validator is stopped, the runners are stopped as well. validatorCtx, validatorCancel := context.WithCancel(c.ctx) - dutyRunners, err := SetupRunners(validatorCtx, share, operator, c.validatorRegistrationSubmitter, c.validatorStore, c.validatorCommonOpts) + // startEnvelopeDuty lets the proposer kick off the §6 envelope duty after a self-build §4 block. It + // dispatches async on the validator-scoped context (not the proposer's post-consensus ctx, which ends + // with the block duty); c.ExecuteDuty routes by pubkey back to this validator. + startEnvelopeDuty := func(slot phase0.Slot) { + go c.ExecuteDuty(validatorCtx, c.logger, &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleEnvelopeBuilder, + PubKey: phase0.BLSPubKey(share.ValidatorPubKey), + Slot: slot, + ValidatorIndex: share.ValidatorIndex, + }) + } + + dutyRunners, err := SetupRunners(validatorCtx, share, operator, c.validatorRegistrationSubmitter, c.validatorStore, c.validatorCommonOpts, startEnvelopeDuty) if err != nil { validatorCancel() return nil, true, fmt.Errorf("could not setup runners: %w", err) @@ -1161,6 +1173,7 @@ func SetupRunners( validatorRegistrationSubmitter runner.ValidatorRegistrationSubmitter, validatorStore registrystorage.ValidatorStore, options *validator.CommonOptions, + startEnvelopeDuty func(phase0.Slot), ) (runner.ValidatorDutyRunners, error) { if options.ExporterMode { return nil, fmt.Errorf("cannot set up duty runners in exporter mode") @@ -1227,6 +1240,7 @@ func SetupRunners( Graffiti: options.Graffiti, ProposerDelay: options.ProposerDelay, ProposedBlockRoots: proposedBlockRoots, + StartEnvelopeDuty: startEnvelopeDuty, }) case spectypes.RoleEnvelopeBuilder: // The §6 envelope runner shares the proposer's proposedBlockRoots (it reads the §4 root the diff --git a/operator/validator/controller_test.go b/operator/validator/controller_test.go index d9361e3d3a..9e9ad76594 100644 --- a/operator/validator/controller_test.go +++ b/operator/validator/controller_test.go @@ -155,6 +155,7 @@ func TestSetupRunnersExporter(t *testing.T) { &validator.CommonOptions{ ExporterMode: true, }, + nil, // startEnvelopeDuty ) require.Nil(t, runners) require.ErrorContains(t, err, "cannot set up duty runners in exporter mode") @@ -1558,7 +1559,7 @@ func TestSetupRunnersProposerF(t *testing.T) { NetworkConfig: netCfg, } - runners, err := SetupRunners(t.Context(), share, operator, nil, nil, options) + runners, err := SetupRunners(t.Context(), share, operator, nil, nil, options, nil) require.NoError(t, err) require.Contains(t, runners, types.RoleAggregator) @@ -1602,7 +1603,7 @@ func TestSetupRunnersProposerFPostBooleFork(t *testing.T) { NetworkConfig: netCfg, } - runners, err := SetupRunners(t.Context(), share, operator, nil, nil, options) + runners, err := SetupRunners(t.Context(), share, operator, nil, nil, options, nil) require.NoError(t, err) // Post-Boole the standalone aggregator runner is not built (SetupRunners gates it behind diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 23d1e106c7..5a6a763e70 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -130,6 +130,7 @@ type BeaconNode interface { PTCCalls ProposerPreferencesCalls GloasProposerCalls + GloasEnvelopeCalls DomainCalls beaconDuties @@ -172,3 +173,14 @@ type GloasProposerCalls interface { // SubmitGloasBeaconBlock publishes a signed Gloas block. SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error } + +// GloasEnvelopeCalls is the beacon-node surface for the §6 execution-payload envelope (SIP #94 §6): +// fetching the payload the proposer committed to (self-build) and publishing the signed envelope. Like +// the block calls, these are hand-rolled over HTTP (beacon-APIs#580, unmerged) — verify on a Gloas devnet. +type GloasEnvelopeCalls interface { + // GetExecutionPayloadEnvelope fetches the execution-payload envelope for the proposer's committed + // block, to be blinded, agreed in §6 QBFT, and signed. + GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) + // SubmitExecutionPayloadEnvelope publishes the signed envelope. + SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error +} diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index c33c776e0f..e12ab0e4d9 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -830,6 +830,21 @@ func (mr *MockBeaconNodeMockRecorder) GetBeaconBlock(ctx, slot, graffiti, randao return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).GetBeaconBlock), ctx, slot, graffiti, randao) } +// GetExecutionPayloadEnvelope mocks base method. +func (m *MockBeaconNode) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecutionPayloadEnvelope", ctx, slot, beaconBlockRoot) + ret0, _ := ret[0].(*gloas.ExecutionPayloadEnvelope) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutionPayloadEnvelope indicates an expected call of GetExecutionPayloadEnvelope. +func (mr *MockBeaconNodeMockRecorder) GetExecutionPayloadEnvelope(ctx, slot, beaconBlockRoot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionPayloadEnvelope", reflect.TypeOf((*MockBeaconNode)(nil).GetExecutionPayloadEnvelope), ctx, slot, beaconBlockRoot) +} + // GetGloasBeaconBlock mocks base method. func (m *MockBeaconNode) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { m.ctrl.T.Helper() @@ -1034,6 +1049,20 @@ func (mr *MockBeaconNodeMockRecorder) SubmitBeaconCommitteeSubscriptions(ctx, su return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBeaconCommitteeSubscriptions", reflect.TypeOf((*MockBeaconNode)(nil).SubmitBeaconCommitteeSubscriptions), ctx, subscription) } +// SubmitExecutionPayloadEnvelope mocks base method. +func (m *MockBeaconNode) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitExecutionPayloadEnvelope", ctx, signed) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitExecutionPayloadEnvelope indicates an expected call of SubmitExecutionPayloadEnvelope. +func (mr *MockBeaconNodeMockRecorder) SubmitExecutionPayloadEnvelope(ctx, signed any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitExecutionPayloadEnvelope", reflect.TypeOf((*MockBeaconNode)(nil).SubmitExecutionPayloadEnvelope), ctx, signed) +} + // SubmitGloasBeaconBlock mocks base method. func (m *MockBeaconNode) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { m.ctrl.T.Helper() @@ -1390,3 +1419,56 @@ func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block) } + +// MockGloasEnvelopeCalls is a mock of GloasEnvelopeCalls interface. +type MockGloasEnvelopeCalls struct { + ctrl *gomock.Controller + recorder *MockGloasEnvelopeCallsMockRecorder + isgomock struct{} +} + +// MockGloasEnvelopeCallsMockRecorder is the mock recorder for MockGloasEnvelopeCalls. +type MockGloasEnvelopeCallsMockRecorder struct { + mock *MockGloasEnvelopeCalls +} + +// NewMockGloasEnvelopeCalls creates a new mock instance. +func NewMockGloasEnvelopeCalls(ctrl *gomock.Controller) *MockGloasEnvelopeCalls { + mock := &MockGloasEnvelopeCalls{ctrl: ctrl} + mock.recorder = &MockGloasEnvelopeCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGloasEnvelopeCalls) EXPECT() *MockGloasEnvelopeCallsMockRecorder { + return m.recorder +} + +// GetExecutionPayloadEnvelope mocks base method. +func (m *MockGloasEnvelopeCalls) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecutionPayloadEnvelope", ctx, slot, beaconBlockRoot) + ret0, _ := ret[0].(*gloas.ExecutionPayloadEnvelope) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutionPayloadEnvelope indicates an expected call of GetExecutionPayloadEnvelope. +func (mr *MockGloasEnvelopeCallsMockRecorder) GetExecutionPayloadEnvelope(ctx, slot, beaconBlockRoot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).GetExecutionPayloadEnvelope), ctx, slot, beaconBlockRoot) +} + +// SubmitExecutionPayloadEnvelope mocks base method. +func (m *MockGloasEnvelopeCalls) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitExecutionPayloadEnvelope", ctx, signed) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitExecutionPayloadEnvelope indicates an expected call of SubmitExecutionPayloadEnvelope. +func (mr *MockGloasEnvelopeCallsMockRecorder) SubmitExecutionPayloadEnvelope(ctx, signed any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).SubmitExecutionPayloadEnvelope), ctx, signed) +} diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index f581dc45d3..cd8fd86203 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -1,6 +1,7 @@ package runner import ( + "bytes" "context" "crypto/sha256" "encoding/json" @@ -15,6 +16,7 @@ import ( "github.com/ssvlabs/ssv/ssvsigner/ekm" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" @@ -51,6 +53,11 @@ type EnvelopeBuilderRunner struct { // proposedBlockRoots gives executeDuty the §4-decided block root for the slot (the envelope's // BeaconBlockRoot), recorded by the proposer runner. Shared with ValCheck, which checks the same root. proposedBlockRoots *ssv.ProposedBlockRoots + + // cachedEnvelope holds the full envelope this operator fetched in produce. Post-consensus content-matches + // it against the decided blinded value to detect whether this operator built it — only that operator + // publishes the full SignedExecutionPayloadEnvelope. + cachedEnvelope *gloas.ExecutionPayloadEnvelope } // EnvelopeBuilderRunnerOptions bundles the dependencies required by NewEnvelopeBuilderRunner. @@ -196,17 +203,45 @@ func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger return r.submitEnvelope(ctx, logger, cd, specSig) } -// submitEnvelope publishes the signed execution-payload envelope. Only the operator that built the decided -// envelope (content match) publishes; the others just complete the duty. -// -// STUB: reconstructing and POSTing the full SignedExecutionPayloadEnvelope needs the full Gloas execution -// payload (EIP-7928 block_access_list, EIP-7843 slot_number) and the goclient submit endpoint. +// submitEnvelope publishes the signed execution-payload envelope. Only the operator whose cached envelope +// blinds to the decided value (content match) holds the full bytes to publish; the others just complete +// the duty — mirroring the §4 block path. func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { - return errors.New("gloas envelope publication not yet implemented (needs the full Gloas execution payload)") + if r.builtDecidedEnvelope(cd.DataSSZ) { + signed := &gloas.SignedExecutionPayloadEnvelope{Message: r.cachedEnvelope, Signature: sig} + if err := r.GetBeaconNode().SubmitExecutionPayloadEnvelope(ctx, signed); err != nil { + return fmt.Errorf("submit execution payload envelope: %w", err) + } + logger.Info("✅ published execution payload envelope") + } else { + logger.Debug("this operator did not build the decided envelope, skipping publication") + } + + r.markDutySucceeded() + r.measurements.EndDutyFlow() + return nil +} + +// builtDecidedEnvelope reports whether this operator's cached envelope blinds to the decided value — i.e. +// it produced the agreed envelope and so holds the full bytes to publish. +func (r *EnvelopeBuilderRunner) builtDecidedEnvelope(decidedDataSSZ []byte) bool { + if r.cachedEnvelope == nil { + return false + } + blinded, err := r.cachedEnvelope.Blinded() + if err != nil { + return false + } + blindedSSZ, err := blinded.Encode() + if err != nil { + return false + } + return bytes.Equal(blindedSSZ, decidedDataSSZ) } func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { r.measurements.StartDutyFlow() + r.cachedEnvelope = nil // drop any envelope cached for a prior duty validatorDuty, err := validatorDutyFromDuty(duty) if err != nil { @@ -237,13 +272,28 @@ func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Log return nil } -// produceBlindedEnvelope fetches this operator's execution-payload envelope for the slot and wraps its -// blinded form as the QBFT value. -// -// STUB: needs the full Gloas execution payload to compute PayloadRoot = hash_tree_root(payload) and the -// goclient GET getExecutionPayloadEnvelope(slot, beacon_block_root). +// produceBlindedEnvelope fetches this operator's execution-payload envelope for the slot, caches the full +// envelope for the later content-matched publish, and wraps its blinded form as the QBFT value. func (r *EnvelopeBuilderRunner) produceBlindedEnvelope(ctx context.Context, duty *spectypes.ValidatorDuty, beaconBlockRoot phase0.Root) (*gloas.EnvelopeConsensusData, error) { - return nil, errors.New("gloas envelope production not yet implemented (needs the full Gloas execution payload)") + envelope, err := r.GetBeaconNode().GetExecutionPayloadEnvelope(ctx, duty.DutySlot(), beaconBlockRoot) + if err != nil { + return nil, fmt.Errorf("get execution payload envelope: %w", err) + } + r.cachedEnvelope = envelope + + blinded, err := envelope.Blinded() + if err != nil { + return nil, err + } + dataSSZ, err := blinded.Encode() + if err != nil { + return nil, fmt.Errorf("encode blinded envelope: %w", err) + } + return &gloas.EnvelopeConsensusData{ + Duty: *duty, + Version: networkconfig.DataVersionGloas, + DataSSZ: dataSSZ, + }, nil } // expectedPreConsensusRootsAndDomain is unreachable: the envelope duty has no pre-consensus phase. diff --git a/protocol/v2/ssv/runner/envelope_test.go b/protocol/v2/ssv/runner/envelope_test.go index 6d855f394e..dbaabff0db 100644 --- a/protocol/v2/ssv/runner/envelope_test.go +++ b/protocol/v2/ssv/runner/envelope_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" "github.com/ssvlabs/ssv/protocol/v2/ssv" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) @@ -67,10 +68,8 @@ func TestEnvelopeBuilderRunner_NoPreConsensus(t *testing.T) { require.Error(t, err) } -// executeDuty requires the proposer to have recorded the §4 block root for the slot, then stubs the -// (heavy-payload-dependent) production. -func TestEnvelopeBuilderRunner_ExecuteDuty(t *testing.T) { - store := ssv.NewProposedBlockRoots() +// executeDuty guards on the proposer having recorded the §4 block root for the slot before producing. +func TestEnvelopeBuilderRunner_ExecuteDutyRequiresDecidedRoot(t *testing.T) { r := &EnvelopeBuilderRunner{ BaseRunner: &BaseRunner{ RunnerRoleType: spectypes.RoleEnvelopeBuilder, @@ -79,20 +78,71 @@ func TestEnvelopeBuilderRunner_ExecuteDuty(t *testing.T) { }, }, measurements: newMeasurementsStore(), - proposedBlockRoots: store, + proposedBlockRoots: ssv.NewProposedBlockRoots(), } duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: 5, ValidatorIndex: 3} - // No recorded §4 root → guarded. require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "no decided block root") +} - // With the root present, production is reached and stubs out pending the full Gloas payload. - store.Set(5, phase0.Root{0xaa}) - require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "not yet implemented") +type envelopeTestBeacon struct { + beacon.BeaconNode + envelope *gloas.ExecutionPayloadEnvelope + submitted []*gloas.SignedExecutionPayloadEnvelope } -func TestEnvelopeBuilderRunner_SubmitEnvelopeStub(t *testing.T) { - r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}} - err := r.submitEnvelope(context.Background(), zap.NewNop(), &gloas.EnvelopeConsensusData{}, phase0.BLSSignature{}) - require.ErrorContains(t, err, "not yet implemented") +func (b *envelopeTestBeacon) GetExecutionPayloadEnvelope(_ context.Context, _ phase0.Slot, _ phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + return b.envelope, nil +} + +func (b *envelopeTestBeacon) SubmitExecutionPayloadEnvelope(_ context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { + b.submitted = append(b.submitted, signed) + return nil +} + +func sampleEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BlockNumber: 42}, + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: gloas.BuilderIndexSelfBuild, + BeaconBlockRoot: phase0.Root{0xaa}, + ParentBeaconBlockRoot: phase0.Root{0xbb}, + } +} + +// produceBlindedEnvelope fetches the envelope, caches the full one, and wraps its blinded form (PayloadRoot +// = HTR(payload), with the §4 root and builder index preserved) as the QBFT value. +func TestEnvelopeBuilderRunner_ProduceBlindedEnvelope(t *testing.T) { + envelope := sampleEnvelope() + r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}, beacon: &envelopeTestBeacon{envelope: envelope}} + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: 5, ValidatorIndex: 3} + + cd, err := r.produceBlindedEnvelope(context.Background(), duty, phase0.Root{0xaa}) + require.NoError(t, err) + require.Same(t, envelope, r.cachedEnvelope) // cached for the later content-matched publish + + blinded := &gloas.BlindedExecutionPayloadEnvelope{} + require.NoError(t, blinded.Decode(cd.DataSSZ)) + wantRoot, err := envelope.Payload.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root(wantRoot), blinded.PayloadRoot) + require.Equal(t, envelope.BeaconBlockRoot, blinded.BeaconBlockRoot) + require.Equal(t, gloas.BuilderIndexSelfBuild, blinded.BuilderIndex) +} + +// builtDecidedEnvelope is the content match: only the operator whose cached envelope blinds to the decided +// value holds the full bytes and publishes. +func TestEnvelopeBuilderRunner_BuiltDecidedEnvelope(t *testing.T) { + envelope := sampleEnvelope() + blinded, err := envelope.Blinded() + require.NoError(t, err) + decided, err := blinded.Encode() + require.NoError(t, err) + + r := &EnvelopeBuilderRunner{cachedEnvelope: envelope} + require.True(t, r.builtDecidedEnvelope(decided)) // our cached envelope blinds to the decided value + require.False(t, r.builtDecidedEnvelope([]byte{0x01})) // a different decided value + + r.cachedEnvelope = nil + require.False(t, r.builtDecidedEnvelope(decided)) // nothing cached (e.g. after a round change) } diff --git a/protocol/v2/types/gloas/execution_payload.go b/protocol/v2/types/gloas/execution_payload.go new file mode 100644 index 0000000000..4eecbfbb9d --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload.go @@ -0,0 +1,41 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. -path is the package dir so sszgen resolves the sibling gloas +// types; includes track go-eth2-client via `go list -m`. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs ExecutionPayload --output ./execution_payload_encoding.go" + +// ExecutionPayload is the Gloas (ePBS) execution payload — Deneb/Electra's payload plus the two +// Glamsterdam additions: BlockAccessList (EIP-7928, an opaque RLP-encoded byte list the consensus layer +// only stores and hashes) and SlotNumber (EIP-7843). It ships in the §6 ExecutionPayloadEnvelope, not +// inline in the block. BaseFeePerGas is the 32-byte little-endian SSZ form of the spec's uint256 +// (HTR-identical). +// +// TODO(gloas §6): confirm the field order and the BlockAccessList ssz-max bound against canonical spec +// test vectors (the §6 HTR-parity test) before relying on the payload root on a live network. +type ExecutionPayload struct { + ParentHash phase0.Hash32 `ssz-size:"32"` + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + StateRoot phase0.Root `ssz-size:"32"` + ReceiptsRoot phase0.Root `ssz-size:"32"` + LogsBloom [256]byte `ssz-size:"256"` + PrevRandao phase0.Hash32 `ssz-size:"32"` + BlockNumber uint64 + GasLimit uint64 + GasUsed uint64 + Timestamp uint64 + ExtraData []byte `ssz-max:"32"` + BaseFeePerGas [32]byte `ssz-size:"32"` + BlockHash phase0.Hash32 `ssz-size:"32"` + Transactions []bellatrix.Transaction `ssz-max:"1048576,1073741824" ssz-size:"?,?"` + Withdrawals []*capella.Withdrawal `ssz-max:"16"` + BlobGasUsed uint64 + ExcessBlobGas uint64 + BlockAccessList []byte `ssz-max:"1073741824"` + SlotNumber uint64 +} diff --git a/protocol/v2/types/gloas/execution_payload_encoding.go b/protocol/v2/types/gloas/execution_payload_encoding.go new file mode 100644 index 0000000000..cdf202d8f2 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_encoding.go @@ -0,0 +1,426 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 197bb55313db70d1c1f371d03f18749bc892b1ca5b1d5da98a3ece3330db2780 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the ExecutionPayload object +func (e *ExecutionPayload) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(e) +} + +// MarshalSSZTo ssz marshals the ExecutionPayload object to a target array +func (e *ExecutionPayload) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(540) + + // Field (0) 'ParentHash' + dst = append(dst, e.ParentHash[:]...) + + // Field (1) 'FeeRecipient' + dst = append(dst, e.FeeRecipient[:]...) + + // Field (2) 'StateRoot' + dst = append(dst, e.StateRoot[:]...) + + // Field (3) 'ReceiptsRoot' + dst = append(dst, e.ReceiptsRoot[:]...) + + // Field (4) 'LogsBloom' + dst = append(dst, e.LogsBloom[:]...) + + // Field (5) 'PrevRandao' + dst = append(dst, e.PrevRandao[:]...) + + // Field (6) 'BlockNumber' + dst = ssz.MarshalUint64(dst, e.BlockNumber) + + // Field (7) 'GasLimit' + dst = ssz.MarshalUint64(dst, e.GasLimit) + + // Field (8) 'GasUsed' + dst = ssz.MarshalUint64(dst, e.GasUsed) + + // Field (9) 'Timestamp' + dst = ssz.MarshalUint64(dst, e.Timestamp) + + // Offset (10) 'ExtraData' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.ExtraData) + + // Field (11) 'BaseFeePerGas' + dst = append(dst, e.BaseFeePerGas[:]...) + + // Field (12) 'BlockHash' + dst = append(dst, e.BlockHash[:]...) + + // Offset (13) 'Transactions' + dst = ssz.WriteOffset(dst, offset) + for ii := 0; ii < len(e.Transactions); ii++ { + offset += 4 + offset += len(e.Transactions[ii]) + } + + // Offset (14) 'Withdrawals' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.Withdrawals) * 44 + + // Field (15) 'BlobGasUsed' + dst = ssz.MarshalUint64(dst, e.BlobGasUsed) + + // Field (16) 'ExcessBlobGas' + dst = ssz.MarshalUint64(dst, e.ExcessBlobGas) + + // Offset (17) 'BlockAccessList' + dst = ssz.WriteOffset(dst, offset) + + // Field (18) 'SlotNumber' + dst = ssz.MarshalUint64(dst, e.SlotNumber) + + // Field (10) 'ExtraData' + if size := len(e.ExtraData); size > 32 { + err = ssz.ErrBytesLengthFn("ExecutionPayload.ExtraData", size, 32) + return + } + dst = append(dst, e.ExtraData...) + + // Field (13) 'Transactions' + if size := len(e.Transactions); size > 1048576 { + err = ssz.ErrListTooBigFn("ExecutionPayload.Transactions", size, 1048576) + return + } + { + offset = 4 * len(e.Transactions) + for ii := 0; ii < len(e.Transactions); ii++ { + dst = ssz.WriteOffset(dst, offset) + offset += len(e.Transactions[ii]) + } + } + for ii := 0; ii < len(e.Transactions); ii++ { + if size := len(e.Transactions[ii]); size > 1073741824 { + err = ssz.ErrBytesLengthFn("ExecutionPayload.Transactions[ii]", size, 1073741824) + return + } + dst = append(dst, e.Transactions[ii]...) + } + + // Field (14) 'Withdrawals' + if size := len(e.Withdrawals); size > 16 { + err = ssz.ErrListTooBigFn("ExecutionPayload.Withdrawals", size, 16) + return + } + for ii := 0; ii < len(e.Withdrawals); ii++ { + if dst, err = e.Withdrawals[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (17) 'BlockAccessList' + if size := len(e.BlockAccessList); size > 1073741824 { + err = ssz.ErrBytesLengthFn("ExecutionPayload.BlockAccessList", size, 1073741824) + return + } + dst = append(dst, e.BlockAccessList...) + + return +} + +// UnmarshalSSZ ssz unmarshals the ExecutionPayload object +func (e *ExecutionPayload) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 540 { + return ssz.ErrSize + } + + tail := buf + var o10, o13, o14, o17 uint64 + + // Field (0) 'ParentHash' + copy(e.ParentHash[:], buf[0:32]) + + // Field (1) 'FeeRecipient' + copy(e.FeeRecipient[:], buf[32:52]) + + // Field (2) 'StateRoot' + copy(e.StateRoot[:], buf[52:84]) + + // Field (3) 'ReceiptsRoot' + copy(e.ReceiptsRoot[:], buf[84:116]) + + // Field (4) 'LogsBloom' + copy(e.LogsBloom[:], buf[116:372]) + + // Field (5) 'PrevRandao' + copy(e.PrevRandao[:], buf[372:404]) + + // Field (6) 'BlockNumber' + e.BlockNumber = ssz.UnmarshallUint64(buf[404:412]) + + // Field (7) 'GasLimit' + e.GasLimit = ssz.UnmarshallUint64(buf[412:420]) + + // Field (8) 'GasUsed' + e.GasUsed = ssz.UnmarshallUint64(buf[420:428]) + + // Field (9) 'Timestamp' + e.Timestamp = ssz.UnmarshallUint64(buf[428:436]) + + // Offset (10) 'ExtraData' + if o10 = ssz.ReadOffset(buf[436:440]); o10 > size { + return ssz.ErrOffset + } + + if o10 != 540 { + return ssz.ErrInvalidVariableOffset + } + + // Field (11) 'BaseFeePerGas' + copy(e.BaseFeePerGas[:], buf[440:472]) + + // Field (12) 'BlockHash' + copy(e.BlockHash[:], buf[472:504]) + + // Offset (13) 'Transactions' + if o13 = ssz.ReadOffset(buf[504:508]); o13 > size || o10 > o13 { + return ssz.ErrOffset + } + + // Offset (14) 'Withdrawals' + if o14 = ssz.ReadOffset(buf[508:512]); o14 > size || o13 > o14 { + return ssz.ErrOffset + } + + // Field (15) 'BlobGasUsed' + e.BlobGasUsed = ssz.UnmarshallUint64(buf[512:520]) + + // Field (16) 'ExcessBlobGas' + e.ExcessBlobGas = ssz.UnmarshallUint64(buf[520:528]) + + // Offset (17) 'BlockAccessList' + if o17 = ssz.ReadOffset(buf[528:532]); o17 > size || o14 > o17 { + return ssz.ErrOffset + } + + // Field (18) 'SlotNumber' + e.SlotNumber = ssz.UnmarshallUint64(buf[532:540]) + + // Field (10) 'ExtraData' + { + buf = tail[o10:o13] + if len(buf) > 32 { + return ssz.ErrBytesLength + } + if cap(e.ExtraData) == 0 { + e.ExtraData = make([]byte, 0, len(buf)) + } + e.ExtraData = append(e.ExtraData, buf...) + } + + // Field (13) 'Transactions' + { + buf = tail[o13:o14] + num, err := ssz.DecodeDynamicLength(buf, 1048576) + if err != nil { + return err + } + e.Transactions = make([]bellatrix.Transaction, num) + err = ssz.UnmarshalDynamic(buf, num, func(indx int, buf []byte) (err error) { + if len(buf) > 1073741824 { + return ssz.ErrBytesLength + } + if cap(e.Transactions[indx]) == 0 { + e.Transactions[indx] = bellatrix.Transaction(make([]byte, 0, len(buf))) + } + e.Transactions[indx] = append(e.Transactions[indx], buf...) + return nil + }) + if err != nil { + return err + } + } + + // Field (14) 'Withdrawals' + { + buf = tail[o14:o17] + num, err := ssz.DivideInt2(len(buf), 44, 16) + if err != nil { + return err + } + e.Withdrawals = make([]*capella.Withdrawal, num) + for ii := 0; ii < num; ii++ { + if e.Withdrawals[ii] == nil { + e.Withdrawals[ii] = new(capella.Withdrawal) + } + if err = e.Withdrawals[ii].UnmarshalSSZ(buf[ii*44 : (ii+1)*44]); err != nil { + return err + } + } + } + + // Field (17) 'BlockAccessList' + { + buf = tail[o17:] + if len(buf) > 1073741824 { + return ssz.ErrBytesLength + } + if cap(e.BlockAccessList) == 0 { + e.BlockAccessList = make([]byte, 0, len(buf)) + } + e.BlockAccessList = append(e.BlockAccessList, buf...) + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the ExecutionPayload object +func (e *ExecutionPayload) SizeSSZ() (size int) { + size = 540 + + // Field (10) 'ExtraData' + size += len(e.ExtraData) + + // Field (13) 'Transactions' + for ii := 0; ii < len(e.Transactions); ii++ { + size += 4 + size += len(e.Transactions[ii]) + } + + // Field (14) 'Withdrawals' + size += len(e.Withdrawals) * 44 + + // Field (17) 'BlockAccessList' + size += len(e.BlockAccessList) + + return +} + +// HashTreeRoot ssz hashes the ExecutionPayload object +func (e *ExecutionPayload) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(e) +} + +// HashTreeRootWith ssz hashes the ExecutionPayload object with a hasher +func (e *ExecutionPayload) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'ParentHash' + hh.PutBytes(e.ParentHash[:]) + + // Field (1) 'FeeRecipient' + hh.PutBytes(e.FeeRecipient[:]) + + // Field (2) 'StateRoot' + hh.PutBytes(e.StateRoot[:]) + + // Field (3) 'ReceiptsRoot' + hh.PutBytes(e.ReceiptsRoot[:]) + + // Field (4) 'LogsBloom' + hh.PutBytes(e.LogsBloom[:]) + + // Field (5) 'PrevRandao' + hh.PutBytes(e.PrevRandao[:]) + + // Field (6) 'BlockNumber' + hh.PutUint64(e.BlockNumber) + + // Field (7) 'GasLimit' + hh.PutUint64(e.GasLimit) + + // Field (8) 'GasUsed' + hh.PutUint64(e.GasUsed) + + // Field (9) 'Timestamp' + hh.PutUint64(e.Timestamp) + + // Field (10) 'ExtraData' + { + elemIndx := hh.Index() + byteLen := uint64(len(e.ExtraData)) + if byteLen > 32 { + err = ssz.ErrIncorrectListSize + return + } + hh.Append(e.ExtraData) + hh.MerkleizeWithMixin(elemIndx, byteLen, (32+31)/32) + } + + // Field (11) 'BaseFeePerGas' + hh.PutBytes(e.BaseFeePerGas[:]) + + // Field (12) 'BlockHash' + hh.PutBytes(e.BlockHash[:]) + + // Field (13) 'Transactions' + { + subIndx := hh.Index() + num := uint64(len(e.Transactions)) + if num > 1048576 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.Transactions { + { + elemIndx := hh.Index() + byteLen := uint64(len(elem)) + if byteLen > 1073741824 { + err = ssz.ErrIncorrectListSize + return + } + hh.AppendBytes32(elem) + hh.MerkleizeWithMixin(elemIndx, byteLen, (1073741824+31)/32) + } + } + hh.MerkleizeWithMixin(subIndx, num, 1048576) + } + + // Field (14) 'Withdrawals' + { + subIndx := hh.Index() + num := uint64(len(e.Withdrawals)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.Withdrawals { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (15) 'BlobGasUsed' + hh.PutUint64(e.BlobGasUsed) + + // Field (16) 'ExcessBlobGas' + hh.PutUint64(e.ExcessBlobGas) + + // Field (17) 'BlockAccessList' + { + elemIndx := hh.Index() + byteLen := uint64(len(e.BlockAccessList)) + if byteLen > 1073741824 { + err = ssz.ErrIncorrectListSize + return + } + hh.Append(e.BlockAccessList) + hh.MerkleizeWithMixin(elemIndx, byteLen, (1073741824+31)/32) + } + + // Field (18) 'SlotNumber' + hh.PutUint64(e.SlotNumber) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the ExecutionPayload object +func (e *ExecutionPayload) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(e) +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index cabf8f5ff9..c34755f19e 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -1,6 +1,8 @@ package gloas import ( + "fmt" + "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -8,7 +10,7 @@ import ( // Regenerate with `go generate ./...`. -path is the package dir (not just this file) so sszgen resolves // the sibling gloas BuilderIndex the envelope references; --objs limits output to the blinded envelope, // collected into its own _encoding.go. Includes track go-eth2-client via `go list -m`. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs BlindedExecutionPayloadEnvelope --output ./execution_payload_envelope_encoding.go" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload --output ./execution_payload_envelope_encoding.go" // BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 // envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by @@ -30,3 +32,46 @@ type BlindedExecutionPayloadEnvelope struct { // Encode/Decode wrap SSZ (de)serialization — the form carried in the §6 QBFT consensus DataSSZ. func (b *BlindedExecutionPayloadEnvelope) Encode() ([]byte, error) { return b.MarshalSSZ() } func (b *BlindedExecutionPayloadEnvelope) Decode(data []byte) error { return b.UnmarshalSSZ(data) } + +// ExecutionPayloadEnvelope is the full (unblinded) Gloas execution-payload envelope (SIP #94 §6). The §6 +// duty signs the blinded form above; this is the body the builder publishes once the cluster reconstructs +// the signature. Its hash-tree root equals the blinded envelope's when PayloadRoot = hash_tree_root(Payload), +// so the signature over the blinded root is valid for this full envelope. +type ExecutionPayloadEnvelope struct { + Payload *ExecutionPayload + ExecutionRequests *electra.ExecutionRequests + BuilderIndex BuilderIndex + BeaconBlockRoot phase0.Root `ssz-size:"32"` + ParentBeaconBlockRoot phase0.Root `ssz-size:"32"` +} + +// SignedExecutionPayloadEnvelope wraps the envelope with the builder's signature (under +// DOMAIN_BEACON_BUILDER) — the §6 publication body on the stateful path. +type SignedExecutionPayloadEnvelope struct { + Message *ExecutionPayloadEnvelope + Signature phase0.BLSSignature `ssz-size:"96"` +} + +func (e *ExecutionPayloadEnvelope) Encode() ([]byte, error) { return e.MarshalSSZ() } +func (e *ExecutionPayloadEnvelope) Decode(data []byte) error { return e.UnmarshalSSZ(data) } + +func (e *SignedExecutionPayloadEnvelope) Encode() ([]byte, error) { return e.MarshalSSZ() } +func (e *SignedExecutionPayloadEnvelope) Decode(data []byte) error { return e.UnmarshalSSZ(data) } + +// Blinded returns the blinded form of the envelope — the full Payload replaced by its hash-tree root. +// The blinded envelope hashes to the same root as this one, so the §6 duty agrees on and signs the +// blinded value while the signature stays valid for this full envelope. The non-Payload fields are +// shared (not copied), so the result must not outlive this envelope. +func (e *ExecutionPayloadEnvelope) Blinded() (*BlindedExecutionPayloadEnvelope, error) { + payloadRoot, err := e.Payload.HashTreeRoot() + if err != nil { + return nil, fmt.Errorf("hash tree root of execution payload: %w", err) + } + return &BlindedExecutionPayloadEnvelope{ + PayloadRoot: payloadRoot, + ExecutionRequests: e.ExecutionRequests, + BuilderIndex: e.BuilderIndex, + BeaconBlockRoot: e.BeaconBlockRoot, + ParentBeaconBlockRoot: e.ParentBeaconBlockRoot, + }, nil +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go index 39f6863132..4ddbd9d36a 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 857c3813ae35f3cb7296d79f5c30b5aea2d198fa9ab4448d4fe0f1f9ceaa82ff +// Hash: a8cf3a06e8b956e381e3d5ab478fc369f2b31391e922bbe837321e1c803550b8 // Version: 0.1.3 package gloas @@ -133,3 +133,258 @@ func (b *BlindedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (e func (b *BlindedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { return ssz.ProofTree(b) } + +// MarshalSSZ ssz marshals the ExecutionPayloadEnvelope object +func (e *ExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(e) +} + +// MarshalSSZTo ssz marshals the ExecutionPayloadEnvelope object to a target array +func (e *ExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(80) + + // Offset (0) 'Payload' + dst = ssz.WriteOffset(dst, offset) + if e.Payload == nil { + e.Payload = new(ExecutionPayload) + } + offset += e.Payload.SizeSSZ() + + // Offset (1) 'ExecutionRequests' + dst = ssz.WriteOffset(dst, offset) + + // Field (2) 'BuilderIndex' + dst = ssz.MarshalUint64(dst, uint64(e.BuilderIndex)) + + // Field (3) 'BeaconBlockRoot' + dst = append(dst, e.BeaconBlockRoot[:]...) + + // Field (4) 'ParentBeaconBlockRoot' + dst = append(dst, e.ParentBeaconBlockRoot[:]...) + + // Field (0) 'Payload' + if dst, err = e.Payload.MarshalSSZTo(dst); err != nil { + return + } + + // Field (1) 'ExecutionRequests' + if dst, err = e.ExecutionRequests.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the ExecutionPayloadEnvelope object +func (e *ExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 80 { + return ssz.ErrSize + } + + tail := buf + var o0, o1 uint64 + + // Offset (0) 'Payload' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 80 { + return ssz.ErrInvalidVariableOffset + } + + // Offset (1) 'ExecutionRequests' + if o1 = ssz.ReadOffset(buf[4:8]); o1 > size || o0 > o1 { + return ssz.ErrOffset + } + + // Field (2) 'BuilderIndex' + e.BuilderIndex = BuilderIndex(ssz.UnmarshallUint64(buf[8:16])) + + // Field (3) 'BeaconBlockRoot' + copy(e.BeaconBlockRoot[:], buf[16:48]) + + // Field (4) 'ParentBeaconBlockRoot' + copy(e.ParentBeaconBlockRoot[:], buf[48:80]) + + // Field (0) 'Payload' + { + buf = tail[o0:o1] + if e.Payload == nil { + e.Payload = new(ExecutionPayload) + } + if err = e.Payload.UnmarshalSSZ(buf); err != nil { + return err + } + } + + // Field (1) 'ExecutionRequests' + { + buf = tail[o1:] + if e.ExecutionRequests == nil { + e.ExecutionRequests = new(electra.ExecutionRequests) + } + if err = e.ExecutionRequests.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the ExecutionPayloadEnvelope object +func (e *ExecutionPayloadEnvelope) SizeSSZ() (size int) { + size = 80 + + // Field (0) 'Payload' + if e.Payload == nil { + e.Payload = new(ExecutionPayload) + } + size += e.Payload.SizeSSZ() + + // Field (1) 'ExecutionRequests' + if e.ExecutionRequests == nil { + e.ExecutionRequests = new(electra.ExecutionRequests) + } + size += e.ExecutionRequests.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the ExecutionPayloadEnvelope object +func (e *ExecutionPayloadEnvelope) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(e) +} + +// HashTreeRootWith ssz hashes the ExecutionPayloadEnvelope object with a hasher +func (e *ExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Payload' + if err = e.Payload.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'ExecutionRequests' + if err = e.ExecutionRequests.HashTreeRootWith(hh); err != nil { + return + } + + // Field (2) 'BuilderIndex' + hh.PutUint64(uint64(e.BuilderIndex)) + + // Field (3) 'BeaconBlockRoot' + hh.PutBytes(e.BeaconBlockRoot[:]) + + // Field (4) 'ParentBeaconBlockRoot' + hh.PutBytes(e.ParentBeaconBlockRoot[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the ExecutionPayloadEnvelope object +func (e *ExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(e) +} + +// MarshalSSZ ssz marshals the SignedExecutionPayloadEnvelope object +func (s *SignedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedExecutionPayloadEnvelope object to a target array +func (s *SignedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(100) + + // Offset (0) 'Message' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + // Field (0) 'Message' + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedExecutionPayloadEnvelope object +func (s *SignedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 100 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Message' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 100 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[4:100]) + + // Field (0) 'Message' + { + buf = tail[o0:] + if s.Message == nil { + s.Message = new(ExecutionPayloadEnvelope) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedExecutionPayloadEnvelope object +func (s *SignedExecutionPayloadEnvelope) SizeSSZ() (size int) { + size = 100 + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(ExecutionPayloadEnvelope) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedExecutionPayloadEnvelope object +func (s *SignedExecutionPayloadEnvelope) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedExecutionPayloadEnvelope object with a hasher +func (s *SignedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedExecutionPayloadEnvelope object +func (s *SignedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go index 560c374100..a485fb2c72 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_test.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -3,6 +3,8 @@ package gloas import ( "testing" + "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/phase0" "github.com/stretchr/testify/require" @@ -33,3 +35,85 @@ func TestBlindedExecutionPayloadEnvelopeRoundTrip(t *testing.T) { require.NoError(t, err) require.Equal(t, r1, r2) } + +func sampleExecutionPayload() *ExecutionPayload { + return &ExecutionPayload{ + ParentHash: phase0.Hash32{0x11}, + FeeRecipient: bellatrix.ExecutionAddress{0x22}, + StateRoot: phase0.Root{0x33}, + ReceiptsRoot: phase0.Root{0x44}, + PrevRandao: phase0.Hash32{0x55}, + BlockNumber: 42, + GasLimit: 30_000_000, + GasUsed: 21_000, + Timestamp: 1_700_000_000, + ExtraData: []byte("ssv"), + BaseFeePerGas: [32]byte{0x66}, + BlockHash: phase0.Hash32{0x77}, + Transactions: []bellatrix.Transaction{{0x01, 0x02}}, + Withdrawals: []*capella.Withdrawal{{Index: 1, ValidatorIndex: 2, Address: bellatrix.ExecutionAddress{0x88}, Amount: 99}}, + BlobGasUsed: 1, + ExcessBlobGas: 2, + BlockAccessList: []byte{0xaa, 0xbb, 0xcc}, + SlotNumber: 7, + } +} + +// The full Gloas ExecutionPayload round-trips through SSZ with a stable root. +func TestExecutionPayloadRoundTrip(t *testing.T) { + in := sampleExecutionPayload() + b, err := in.MarshalSSZ() + require.NoError(t, err) + + out := &ExecutionPayload{} + require.NoError(t, out.UnmarshalSSZ(b)) + + r1, err := in.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} + +// The full envelope round-trips through SSZ with a stable root. +func TestExecutionPayloadEnvelopeRoundTrip(t *testing.T) { + in := &ExecutionPayloadEnvelope{ + Payload: sampleExecutionPayload(), + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: BuilderIndexSelfBuild, + BeaconBlockRoot: phase0.Root{0x02}, + ParentBeaconBlockRoot: phase0.Root{0x03}, + } + b, err := in.MarshalSSZ() + require.NoError(t, err) + + out := &ExecutionPayloadEnvelope{} + require.NoError(t, out.UnmarshalSSZ(b)) + + r1, err := in.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} + +// The blinding property §6 relies on: the full envelope's root equals the blinded envelope's when +// PayloadRoot = hash_tree_root(Payload), so a signature over the blinded root is valid for the full one. +func TestExecutionPayloadEnvelopeBlindsToSameRoot(t *testing.T) { + full := &ExecutionPayloadEnvelope{ + Payload: sampleExecutionPayload(), + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: BuilderIndexSelfBuild, + BeaconBlockRoot: phase0.Root{0x02}, + ParentBeaconBlockRoot: phase0.Root{0x03}, + } + + blinded, err := full.Blinded() + require.NoError(t, err) + + fullRoot, err := full.HashTreeRoot() + require.NoError(t, err) + blindedRoot, err := blinded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, blindedRoot, fullRoot, "blinded envelope must hash to the same root as the full envelope") +} From a77d70dbec16f6c58be9cac0f6276add659e635b Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 17:40:47 +0300 Subject: [PATCH 051/150] gloas: drop the unused full/signed envelope Encode/Decode wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goclient marshals the full and signed envelopes via MarshalSSZ/UnmarshalSSZ directly, so their Encode/Decode aliases were never called. The blinded envelope's wrappers stay — they carry the QBFT DataSSZ. --- protocol/v2/types/gloas/execution_payload_envelope.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index c34755f19e..d10191f365 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -52,12 +52,6 @@ type SignedExecutionPayloadEnvelope struct { Signature phase0.BLSSignature `ssz-size:"96"` } -func (e *ExecutionPayloadEnvelope) Encode() ([]byte, error) { return e.MarshalSSZ() } -func (e *ExecutionPayloadEnvelope) Decode(data []byte) error { return e.UnmarshalSSZ(data) } - -func (e *SignedExecutionPayloadEnvelope) Encode() ([]byte, error) { return e.MarshalSSZ() } -func (e *SignedExecutionPayloadEnvelope) Decode(data []byte) error { return e.UnmarshalSSZ(data) } - // Blinded returns the blinded form of the envelope — the full Payload replaced by its hash-tree root. // The blinded envelope hashes to the same root as this one, so the §6 duty agrees on and signs the // blinded value while the signature stays valid for this full envelope. The non-Payload fields are From 9c7fbdec0c8d34d1b878e8639c9199cac48aeb60 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 17:59:09 +0300 Subject: [PATCH 052/150] =?UTF-8?q?gloas:=20test=20the=20=C2=A76=20envelop?= =?UTF-8?q?e=20runner's=20post-consensus=20publish=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the proposer's heavyweight runner harness (real keyset, QBFT controller, decided RunningInstance) for the envelope runner and cover the post-consensus publish path two ways: - direct submitEnvelope: the builder (its cached envelope blinds to the decided value) publishes the full SignedExecutionPayloadEnvelope; an operator that produced a competing envelope completes the duty without publishing. - full ProcessPostConsensus: a threshold of share-signed partial signatures under DOMAIN_BEACON_BUILDER is reconstructed into the envelope signature, which the builder then publishes. --- protocol/v2/ssv/runner/envelope_e2e_test.go | 214 ++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 protocol/v2/ssv/runner/envelope_e2e_test.go diff --git a/protocol/v2/ssv/runner/envelope_e2e_test.go b/protocol/v2/ssv/runner/envelope_e2e_test.go new file mode 100644 index 0000000000..bd663cfd05 --- /dev/null +++ b/protocol/v2/ssv/runner/envelope_e2e_test.go @@ -0,0 +1,214 @@ +package runner + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + specqbft "github.com/ssvlabs/ssv-spec/qbft" + spectypes "github.com/ssvlabs/ssv-spec/types" + spectestingutils "github.com/ssvlabs/ssv-spec/types/testingutils" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/qbft/instance" + "github.com/ssvlabs/ssv/protocol/v2/qbft/roundtimer" + "github.com/ssvlabs/ssv/protocol/v2/ssv" + protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" +) + +func envelopeDuty(slot phase0.Slot) *spectypes.ValidatorDuty { + return &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleEnvelopeBuilder, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: slot, + ValidatorIndex: spectestingutils.TestingValidatorIndex, + } +} + +// newEnvelopeTestBeacon embeds the spec testing beacon so DomainData (used by the post-consensus +// signing-root computation) resolves, while still recording the published envelopes. +func newEnvelopeTestBeacon() *envelopeTestBeacon { + return &envelopeTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped()} +} + +func newEnvelopeBuilderRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*EnvelopeBuilderRunner, *spectestingutils.TestKeySet) { + t.Helper() + + cfg := cloneTestNetworkConfig() + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleEnvelopeBuilder) + network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) + km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) + operator := spectestingutils.TestingCommitteeMember(keySet) + operatorSigner := spectestingutils.NewOperatorSigner(keySet, 1) + + qbftConfig := protocoltesting.TestingConfig(zap.NewNop(), keySet) + qbftConfig.ProposerF = func(*specqbft.State, specqbft.Round) spectypes.OperatorID { return 1 } + qbftConfig.Network = network + controller := protocoltesting.NewTestingQBFTController(keySet, identifier[:], operator, qbftConfig, false) + + runnerIface, err := NewEnvelopeBuilderRunner(EnvelopeBuilderRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: network, + Signer: km, + OperatorSigner: operatorSigner, + }, + QBFTController: controller, + ProposedBlockRoots: ssv.NewProposedBlockRoots(), + }) + require.NoError(t, err) + + r := runnerIface.(*EnvelopeBuilderRunner) + r.SetQBFTRoundTimerF(func(context.Context, *zap.Logger, phase0.Slot) ssv.QBFTRoundTimer { + return roundtimer.NewTestingTimer() + }) + return r, keySet +} + +// setupEnvelopeRunnerForPostConsensus puts the runner in the decided state it would reach after §6 QBFT, +// so the post-consensus publish path can be exercised directly (mirrors setupRunnerForPostConsensus). +func setupEnvelopeRunnerForPostConsensus(t *testing.T, runner *EnvelopeBuilderRunner, keySet *spectestingutils.TestKeySet, duty *spectypes.ValidatorDuty, cd *gloas.EnvelopeConsensusData) { + t.Helper() + + runner.State = NewRunnerState(keySet.Threshold, duty) + runner.measurements.StartDutyFlow() + runner.measurements.StartConsensus() + runner.measurements.EndConsensus() + runner.measurements.StartPostConsensus() + + encoded, err := cd.Encode() + require.NoError(t, err) + runner.State.DecidedValue = encoded + + msgID := spectypes.NewMsgID(runner.NetworkConfig.DomainType, runner.GetShare().ValidatorPubKey[:], runner.RunnerRoleType) + qbftConfig := protocoltesting.TestingConfig(zap.NewNop(), keySet) + qbftConfig.ProposerF = func(*specqbft.State, specqbft.Round) spectypes.OperatorID { return 1 } + qbftConfig.Network = runner.network + runner.State.RunningInstance = instance.NewInstance( + t.Context(), zap.NewNop(), qbftConfig, spectestingutils.TestingCommitteeMember(keySet), + msgID[:], specqbft.Height(duty.Slot), runner.operatorSigner, + func(context.Context, *zap.Logger, phase0.Slot) ssv.QBFTRoundTimer { + return roundtimer.NewTestingTimer() + }, + ) + runner.State.RunningInstance.State.Decided = true + runner.State.RunningInstance.State.DecidedValue = encoded +} + +func decidedEnvelopeConsensusData(t *testing.T, slot phase0.Slot, envelope *gloas.ExecutionPayloadEnvelope) *gloas.EnvelopeConsensusData { + t.Helper() + blinded, err := envelope.Blinded() + require.NoError(t, err) + dataSSZ, err := blinded.Encode() + require.NoError(t, err) + return &gloas.EnvelopeConsensusData{Duty: *envelopeDuty(slot), DataSSZ: dataSSZ} +} + +// The builder — its cached envelope blinds to the decided value — publishes the full signed envelope. +func TestEnvelopeBuilderRunner_SubmitEnvelopeBuilderPublishes(t *testing.T) { + const slot = phase0.Slot(8) + envelope := sampleEnvelope() + cd := decidedEnvelopeConsensusData(t, slot, envelope) + + bn := newEnvelopeTestBeacon() + runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) + runner.cachedEnvelope = envelope // this operator built the decided envelope + + err := runner.submitEnvelope(context.Background(), zap.NewNop(), cd, phase0.BLSSignature{0xab}) + require.NoError(t, err) + + require.Len(t, bn.submitted, 1) + require.Equal(t, envelope, bn.submitted[0].Message) + require.Equal(t, phase0.BLSSignature{0xab}, bn.submitted[0].Signature) + require.True(t, runner.State.Succeeded) +} + +// An operator that produced a competing envelope (content mismatch) completes the duty without publishing — +// only the builder of the decided envelope holds the matching full bytes. +func TestEnvelopeBuilderRunner_SubmitEnvelopeNonBuilderSkips(t *testing.T) { + const slot = phase0.Slot(8) + cd := decidedEnvelopeConsensusData(t, slot, sampleEnvelope()) + + bn := newEnvelopeTestBeacon() + runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) + + // This operator's cached envelope differs from the decided one (it lost the round), so it does not + // blind to the decided value. + competing := sampleEnvelope() + competing.Payload.BlockNumber = 99 + runner.cachedEnvelope = competing + + err := runner.submitEnvelope(context.Background(), zap.NewNop(), cd, phase0.BLSSignature{0xab}) + require.NoError(t, err) + + require.Empty(t, bn.submitted) + require.True(t, runner.State.Succeeded) +} + +// processEnvelopePostConsensusQuorum feeds a threshold of post-consensus partial signatures over the decided +// blinded envelope root (signed under DOMAIN_BEACON_BUILDER with the share keys), driving the runner to +// reconstruct the envelope signature. Mirrors processPostConsensusQuorum — the envelope role has no spec +// message helper, so the partial signatures are built here. +func processEnvelopePostConsensusQuorum(t *testing.T, runner *EnvelopeBuilderRunner, keySet *spectestingutils.TestKeySet, blinded *gloas.BlindedExecutionPayloadEnvelope, slot phase0.Slot) { + t.Helper() + + signer := spectestingutils.NewTestingKeyManager() + // epoch doesn't matter here — the testing beacon's domain is epoch-invariant, so it matches the + // domain the runner derives at the duty's epoch. + domain, err := spectestingutils.NewTestingBeaconNode().DomainData(1, spectypes.DomainBeaconBuilder) + require.NoError(t, err) + + root, err := blinded.HashTreeRoot() + require.NoError(t, err) + hashRoot := spectypes.SSZ32Bytes(root) + + for opID := spectypes.OperatorID(1); opID <= keySet.Threshold; opID++ { + sig, sr, err := signer.SignBeaconObject(hashRoot, domain, keySet.Shares[opID].GetPublicKey().Serialize(), spectypes.DomainBeaconBuilder) + require.NoError(t, err) + blsSig := phase0.BLSSignature{} + copy(blsSig[:], sig) + + require.NoError(t, runner.ProcessPostConsensus(context.Background(), zap.NewNop(), &spectypes.PartialSignatureMessages{ + Type: spectypes.PostConsensusPartialSig, + Slot: slot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: blsSig[:], + SigningRoot: sr, + Signer: opID, + ValidatorIndex: spectestingutils.TestingValidatorIndex, + }}, + })) + } +} + +// ProcessPostConsensus collects a quorum of partial signatures, reconstructs the BLS signature, and (as the +// builder) publishes the full signed envelope carrying it. +func TestEnvelopeBuilderRunner_ProcessPostConsensusReconstructsAndPublishes(t *testing.T) { + const slot = phase0.Slot(8) + envelope := sampleEnvelope() + cd := decidedEnvelopeConsensusData(t, slot, envelope) + + bn := newEnvelopeTestBeacon() + runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) + runner.cachedEnvelope = envelope // this operator built the decided envelope + + blinded, err := envelope.Blinded() + require.NoError(t, err) + processEnvelopePostConsensusQuorum(t, runner, keySet, blinded, slot) + + require.Len(t, bn.submitted, 1) + require.Equal(t, envelope, bn.submitted[0].Message) + require.NotEqual(t, phase0.BLSSignature{}, bn.submitted[0].Signature) // the reconstructed signature + require.True(t, runner.State.Succeeded) +} From e6d6a343bd30a4c7db1fa32ab8c6ced07e130a50 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 26 Jun 2026 19:45:22 +0300 Subject: [PATCH 053/150] =?UTF-8?q?gloas:=20verify=20the=20=C2=A76=20Execu?= =?UTF-8?q?tionPayload=20layout=20against=20the=20canonical=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirm the Gloas ExecutionPayload field order, the BlockAccessList ssz-max bound (ByteList[2**30]), and slot_number placement match the canonical container in the pinned consensus-specs Gloas spec, resolving the layout TODO. Add TestExecutionPayloadLayoutMatchesSpec, which pins the hash-tree root of a fully-populated payload so a later field reorder, type, or bound change is caught. --- protocol/v2/types/gloas/execution_payload.go | 14 +++--- .../v2/types/gloas/execution_payload_test.go | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 protocol/v2/types/gloas/execution_payload_test.go diff --git a/protocol/v2/types/gloas/execution_payload.go b/protocol/v2/types/gloas/execution_payload.go index 4eecbfbb9d..885cc72b84 100644 --- a/protocol/v2/types/gloas/execution_payload.go +++ b/protocol/v2/types/gloas/execution_payload.go @@ -10,14 +10,14 @@ import ( // types; includes track go-eth2-client via `go list -m`. //go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs ExecutionPayload --output ./execution_payload_encoding.go" -// ExecutionPayload is the Gloas (ePBS) execution payload — Deneb/Electra's payload plus the two -// Glamsterdam additions: BlockAccessList (EIP-7928, an opaque RLP-encoded byte list the consensus layer -// only stores and hashes) and SlotNumber (EIP-7843). It ships in the §6 ExecutionPayloadEnvelope, not -// inline in the block. BaseFeePerGas is the 32-byte little-endian SSZ form of the spec's uint256 -// (HTR-identical). +// ExecutionPayload is the Gloas (ePBS) execution payload — Deneb's payload plus the two Glamsterdam +// additions: BlockAccessList (EIP-7928, an opaque RLP-encoded byte list the consensus layer only stores +// and hashes) and SlotNumber (EIP-7843). It ships in the §6 ExecutionPayloadEnvelope, not inline in the +// block. BaseFeePerGas is the 32-byte little-endian SSZ form of the spec's uint256 (HTR-identical). // -// TODO(gloas §6): confirm the field order and the BlockAccessList ssz-max bound against canonical spec -// test vectors (the §6 HTR-parity test) before relying on the payload root on a live network. +// The field order and bounds were verified against the canonical container (consensus-specs +// specs/gloas/beacon-chain.md at 6ebb2216c) — block_access_list is ByteList[2**30] +// (MAX_BYTES_PER_TRANSACTION). TestExecutionPayloadLayoutMatchesSpec pins this layout. type ExecutionPayload struct { ParentHash phase0.Hash32 `ssz-size:"32"` FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` diff --git a/protocol/v2/types/gloas/execution_payload_test.go b/protocol/v2/types/gloas/execution_payload_test.go new file mode 100644 index 0000000000..77aaf127a2 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_test.go @@ -0,0 +1,44 @@ +package gloas + +import ( + "encoding/hex" + "testing" + + "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/stretchr/testify/require" +) + +// TestExecutionPayloadLayoutMatchesSpec pins the hash-tree root of a fully-populated ExecutionPayload, +// guarding the SSZ field order and bounds that were verified against the canonical Gloas container +// (consensus-specs specs/gloas/beacon-chain.md at 6ebb2216c). Each field carries a distinct value, so any +// reorder, type change, or bound change moves the root. If this fails after an intentional struct change, +// re-verify the layout against the spec before updating the golden value. +func TestExecutionPayloadLayoutMatchesSpec(t *testing.T) { + payload := &ExecutionPayload{ + ParentHash: phase0.Hash32{0x01}, + FeeRecipient: bellatrix.ExecutionAddress{0x02}, + StateRoot: phase0.Root{0x03}, + ReceiptsRoot: phase0.Root{0x04}, + LogsBloom: [256]byte{0x05}, + PrevRandao: phase0.Hash32{0x06}, + BlockNumber: 7, + GasLimit: 8, + GasUsed: 9, + Timestamp: 10, + ExtraData: []byte{0x0b}, + BaseFeePerGas: [32]byte{0x0c}, + BlockHash: phase0.Hash32{0x0d}, + Transactions: []bellatrix.Transaction{{0x0e}}, + Withdrawals: []*capella.Withdrawal{{Index: 0x0f}}, + BlobGasUsed: 16, + ExcessBlobGas: 17, + BlockAccessList: []byte{0x12}, + SlotNumber: 19, + } + + root, err := payload.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, "6db7211d8fd726d055ee254300728ecec0dc4c1dd02616037621c7d8f4d4e5fb", hex.EncodeToString(root[:])) +} From cce4765205ec7af2765d9866b5e505a26e3ba8b2 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:18:59 +0300 Subject: [PATCH 054/150] =?UTF-8?q?gloas:=20every=20operator=20submits=20t?= =?UTF-8?q?he=20=C2=A74=20block=20(restore=20liveness=20redundancy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gloas (ePBS) §4 block is bid-only — the payload ships separately in the §6 envelope — so the QBFT-decided value is the complete block, held by every operator, and submission is idempotent at the BN by root. Restricting submission to the one content-matched builder was a carry-over of the pre-Gloas full-block rule (where only the builder holds the bytes); it doesn't apply here and silently dropped the pre-Gloas all-operators-submit redundancy — a single builder hiccup after consensus would lose the proposal for the slot. Every operator now submits the decided block, matching the pre-Gloas blinded path, and the now-unused cachedGloasBlockSSZ content-match machinery is removed. The §6 envelope content-match is unaffected — there the decided value is blinded, so only the builder holds the full payload. --- protocol/v2/ssv/runner/proposer.go | 36 ++++++++----------------- protocol/v2/ssv/runner/proposer_test.go | 34 ++++------------------- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 5e166d49b3..3dac4b40d2 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -61,11 +61,6 @@ type ProposerRunner struct { // for efficient validation (so we re-use it instead of re-calculating). cachedBlindedBlockSSZ []byte - // cachedGloasBlockSSZ holds the SSZ of the Gloas (ePBS) block this operator fetched for the duty. - // Post-consensus content-matches it against the decided value to detect whether this operator - // built the decided block — only that operator publishes it (and can later reveal its payload). - cachedGloasBlockSSZ []byte - // proposedBlockRoots records this operator's §4-decided block root per slot so the §6 envelope // runner and its value-check can read it (SIP #94 §6); nil pre-Gloas. proposedBlockRoots *ssv.ProposedBlockRoots @@ -284,7 +279,6 @@ func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Log if err != nil { return nil, fmt.Errorf("could not marshal gloas beacon block: %w", err) } - r.cachedGloasBlockSSZ = byts logFields := []zap.Field{ fields.Slot(duty.Slot), @@ -522,12 +516,11 @@ func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *za return nil } -// submitGloasProposal publishes the decided Gloas (ePBS) block — only the builder (content match) -// submits, the others just complete the duty — then on the self-build path starts the §6 -// envelope-signing duty. The block is decoded up front because every operator needs its bid for the -// self-build check. The envelope trigger fires on every operator (the envelope is a cluster round the -// others join regardless, so the builder joins even if its own submit failed) and dispatches async, so -// it never delays the block. +// submitGloasProposal publishes the decided Gloas (ePBS) block, then on the self-build path starts the §6 +// envelope-signing duty. Every operator submits the decided block — the ePBS block is bid-only so all hold +// it, and submission is idempotent at the BN by root, keeping the pre-Gloas all-submit redundancy. The block +// is decoded up front because every operator needs its bid for the self-build check; the envelope trigger +// fires on every operator and dispatches async, so it never delays the block. func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) if err != nil { @@ -535,19 +528,13 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo } var finishErr error - if bytes.Equal(r.cachedGloasBlockSSZ, cd.DataSSZ) { - start := time.Now() - signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} - if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { - recordFailedSubmission(ctx, spectypes.BNRoleProposer) - finishErr = fmt.Errorf("submit gloas beacon block: %w", err) - } else { - finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) - } + start := time.Now() + signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} + if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { + recordFailedSubmission(ctx, spectypes.BNRoleProposer) + finishErr = fmt.Errorf("submit gloas beacon block: %w", err) } else { - logger.Debug("this operator did not build the decided gloas block, skipping submission") - r.markDutySucceeded() - r.measurements.EndDutyFlow() + finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) } r.triggerEnvelopeIfSelfBuild(block, cd.Duty.Slot) @@ -640,7 +627,6 @@ func (r *ProposerRunner) executeDuty(ctx context.Context, logger *zap.Logger, du // reset the cached original block at the beginning of a new duty r.cachedFullBlock = nil r.cachedBlindedBlockSSZ = nil - r.cachedGloasBlockSSZ = nil // sign partial randao span.AddEvent("signing beacon object") diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 481a434fff..800ed8cea3 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -396,9 +396,9 @@ func gloasProposerConsensusData(t *testing.T, slot phase0.Slot) *spectypes.Propo } } -// The operator that built the decided Gloas block (its cached block content-matches the decided value) -// signs and publishes it. -func TestProposerRunnerSubmitGloasProposalBuilderPublishes(t *testing.T) { +// Every operator submits the decided Gloas block (it is bid-only, so all hold it and submission is +// idempotent at the BN), then completes the duty. +func TestProposerRunnerSubmitGloasProposalSubmits(t *testing.T) { t.Parallel() const slot = phase0.Slot(8) @@ -407,7 +407,6 @@ func TestProposerRunnerSubmitGloasProposalBuilderPublishes(t *testing.T) { runner, keySet, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) - runner.cachedGloasBlockSSZ = append([]byte(nil), consensusData.DataSSZ...) err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{0xab}) require.NoError(t, err) @@ -418,28 +417,8 @@ func TestProposerRunnerSubmitGloasProposalBuilderPublishes(t *testing.T) { require.True(t, runner.State.Succeeded) } -// An operator that did not build the decided block (content mismatch) completes the duty without -// submitting — only the builder can later reveal the matching payload. -func TestProposerRunnerSubmitGloasProposalNonBuilderSkips(t *testing.T) { - t.Parallel() - - const slot = phase0.Slot(8) - consensusData := gloasProposerConsensusData(t, slot) - beacon := newProposerTestBeacon(nil) - runner, keySet, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) - - setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) - runner.cachedGloasBlockSSZ = []byte("a-different-block") - - err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{0xab}) - require.NoError(t, err) - - require.Empty(t, beacon.submittedGloasBlocks) - require.True(t, runner.State.Succeeded) -} - -// gloasProposalInput fetches the Gloas block from the beacon node, wraps it as the consensus value -// with the Gloas version marker, and caches the SSZ for the post-consensus content-match. +// gloasProposalInput fetches the Gloas block from the beacon node and wraps it as the consensus value +// with the Gloas version marker. func TestProposerRunnerGloasProposalInput(t *testing.T) { t.Parallel() @@ -455,7 +434,6 @@ func TestProposerRunnerGloasProposalInput(t *testing.T) { require.NoError(t, err) require.Equal(t, networkconfig.DataVersionGloas, input.Version) require.Equal(t, expectedSSZ, input.DataSSZ) - require.Equal(t, expectedSSZ, runner.cachedGloasBlockSSZ) require.Equal(t, slot, beacon.lastGetSlot) require.Equal(t, []byte("graffiti"), beacon.lastGetGraffiti) require.Equal(t, []byte("randao"), beacon.lastGetRandao) @@ -657,7 +635,6 @@ func TestProposerRunnerSubmitGloasProposalTriggersEnvelopeOnSelfBuild(t *testing consensusData := gloasProposerConsensusData(t, slot) // self-build (TestingBeaconBlock) runner, keySet, _ := newProposerRunnerForTest(t, newProposerTestBeacon(nil), &stubDoppelganger{canSign: true}, 0, nil) setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) - runner.cachedGloasBlockSSZ = []byte("not-the-builder") // non-builder path (no publish needed) var gotSlot phase0.Slot called := false @@ -677,7 +654,6 @@ func TestProposerRunnerSubmitGloasProposalSkipsEnvelopeOnExternalBuild(t *testin consensusData := gloasExternalBuildConsensusData(t, slot) runner, keySet, _ := newProposerRunnerForTest(t, newProposerTestBeacon(nil), &stubDoppelganger{canSign: true}, 0, nil) setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) - runner.cachedGloasBlockSSZ = []byte("not-the-builder") called := false runner.startEnvelopeDuty = func(_ phase0.Slot) { called = true } From 9759a062c98e2636ee1f9b1d8e1155a51191eb98 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:20:40 +0300 Subject: [PATCH 055/150] =?UTF-8?q?gloas:=20fix=20two=20stale=20=C2=A76=20?= =?UTF-8?q?doc=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit envelope.go: the EnvelopeBuilderRunner doc still said produce/publish were "stubbed pending the full Gloas execution payload + goclient endpoints" — both are implemented now (and described in the flow above it), so drop the obsolete note. controller.go: the RoleEnvelopeBuilder registration comment described the trigger as a future "heavy execution-payload piece"; point it at the actual StartEnvelopeDuty callback wired in the RoleProposer case instead. --- operator/validator/controller.go | 2 +- protocol/v2/ssv/runner/envelope.go | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 9dc32f74d1..fd2b6a30c1 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1245,7 +1245,7 @@ func SetupRunners( case spectypes.RoleEnvelopeBuilder: // The §6 envelope runner shares the proposer's proposedBlockRoots (it reads the §4 root the // proposer records). Its value-check is built per duty, so none is passed here. The proposer - // trigger that starts this duty is wired with the heavy execution-payload piece. + // starts this duty via the StartEnvelopeDuty callback wired in the RoleProposer case above. runners[role], err = runner.NewEnvelopeBuilderRunner(runner.EnvelopeBuilderRunnerOptions{ BaseRunnerOptions: baseOpts, QBFTController: buildController(spectypes.RoleEnvelopeBuilder), diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index cd8fd86203..4b136e1f58 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -32,10 +32,6 @@ import ( // ProcessConsensus signs the decided blinded root under DOMAIN_BEACON_BUILDER and broadcasts a // post-consensus partial signature; ProcessPostConsensus reconstructs the BLS signature and the builder // publishes the full envelope. -// -// Produce (fetch the envelope + compute PayloadRoot) and publish (POST the full envelope) are stubbed -// pending the full Gloas execution payload + goclient endpoints; the QBFT / signing / value-check flow is -// complete. type EnvelopeBuilderRunner struct { *BaseRunner From 5a5368dd44753fb4f0ac22f5fab74d4532d14b9d Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:40:02 +0300 Subject: [PATCH 056/150] =?UTF-8?q?ekm:=20add=20the=20=C2=A76=20DomainBeac?= =?UTF-8?q?onBuilder=20signing=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §6 envelope runner signs the blinded envelope under DomainBeaconBuilder, but neither production EKM backend handled that domain — both fell through to "domain unknown". So on a real self-build slot every operator's post-consensus signature failed, the §6 duty never reached quorum, and the payload was never published. The envelope e2e test masked it by signing via the spec's generic key manager rather than LocalKeyManager. Add the arm to both backends, mirroring the PTC/ProposerPreferences treatment: local signs the root via signSSZRoot (no slashing protection); remote returns an explicit unsupported error (Web3Signer has no envelope type, so those operators sign self-build envelopes locally, bounded the same way). Add SignBeaconObject sub-tests for all three Gloas domains, which had no EKM coverage at all. --- ssvsigner/ekm/local_key_manager.go | 4 +++ ssvsigner/ekm/local_key_manager_test.go | 41 +++++++++++++++++++++++++ ssvsigner/ekm/remote_key_manager.go | 6 ++++ 3 files changed, 51 insertions(+) diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index dc04d97f1c..5bd79ea3e6 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -252,6 +252,10 @@ func (km *LocalKeyManager) signBeaconObject( // Gloas (ePBS) proposer preferences: a plain BLS signature over the SSZ root under // DomainProposerPreferences, with no slashing protection. return signSSZRoot(km.signer, obj, domain, pubKey[:]) + case spectypes.DomainBeaconBuilder: + // Gloas (ePBS) §6 execution-payload envelope: a plain BLS signature over the blinded envelope's + // SSZ root under DomainBeaconBuilder, with no slashing protection. + return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") } diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 6b159ae9fa..6da055e56d 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -311,6 +311,47 @@ func TestSignBeaconObject(t *testing.T) { require.NotNil(t, sig) require.NotEqual(t, [32]byte{}, sig) }) + // The Gloas (ePBS) domains sign a generic SSZ root via signSSZRoot (no slashing protection); the obj + // type is incidental — the point is each domain is handled, not falling through to "domain unknown". + t.Run("DomainBeaconBuilder", func(t *testing.T) { + _, sig, err := km.(*LocalKeyManager).SignBeaconObject( + ctx, + spectypes.SSZUint64(1), + phase0.Domain{}, + phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), + currentSlot, + spectypes.DomainBeaconBuilder, + ) + require.NoError(t, err) + require.NotNil(t, sig) + require.NotEqual(t, [32]byte{}, sig) + }) + t.Run("DomainPTCAttester", func(t *testing.T) { + _, sig, err := km.(*LocalKeyManager).SignBeaconObject( + ctx, + spectypes.SSZUint64(1), + phase0.Domain{}, + phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), + currentSlot, + spectypes.DomainPTCAttester, + ) + require.NoError(t, err) + require.NotNil(t, sig) + require.NotEqual(t, [32]byte{}, sig) + }) + t.Run("DomainProposerPreferences", func(t *testing.T) { + _, sig, err := km.(*LocalKeyManager).SignBeaconObject( + ctx, + spectypes.SSZUint64(1), + phase0.Domain{}, + phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), + currentSlot, + spectypes.DomainProposerPreferences, + ) + require.NoError(t, err) + require.NotNil(t, sig) + require.NotEqual(t, [32]byte{}, sig) + }) } func TestRemoveShare(t *testing.T) { diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index cebf77481d..abc6add357 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -415,6 +415,12 @@ func (km *RemoteKeyManager) prepareSignRequest( // those operators must sign locally. // TODO(gloas): route proposer-preferences signing through Web3Signer once it adds the type. return web3signer.SignRequest{}, phase0.Root{}, errors.New("proposer preferences signing is not supported by the remote signer: Web3Signer has no proposer-preferences type, use local signing") + case spectypes.DomainBeaconBuilder: + // Gloas (ePBS) §6 execution-payload envelopes have no Web3Signer request type, so a remote-signing + // operator can't sign them. Bounded (the cluster reconstructs while ≤ f operators are remote-signing), + // but those operators must sign self-build envelopes locally. + // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type. + return web3signer.SignRequest{}, phase0.Root{}, errors.New("execution payload envelope signing is not supported by the remote signer: Web3Signer has no envelope type, use local signing for self-build envelopes") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") } From 6c660a3ab1bf7e32ecb6ed2e1a85233676f6e0ca Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:45:59 +0300 Subject: [PATCH 057/150] =?UTF-8?q?gloas:=20keep=20the=20=C2=A72=20aggrega?= =?UTF-8?q?tion=20index=20on=20Gloas=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeAttestationDataRoot zeroed attData.Index for everything Electra+, and since BeaconForkAtEpoch caps at Fulu a Gloas slot resolves to >= Electra — so the BN's payload-status index (1=FULL) was zeroed, the aggregation root was wrong, and the aggregate fetch missed on FULL-payload slots. SIP #94 §2 requires the root to carry the BN-supplied Gloas index. Gate on IsGloasAtSlot and keep the BN index; the Electra/pre-Electra paths are unchanged. Add a focused computeAttestationDataRoot test for the Gloas case. --- beacon/goclient/aggregator.go | 22 ++++++++++++++++------ beacon/goclient/aggregator_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index b311b0e9ba..e7998415b0 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -115,12 +115,22 @@ func (gc *GoClient) computeAttestationDataRoot( } // Explicitly set Index field as beacon nodes may return inconsistent values. - // EIP-7549: For Electra and later, index must always be 0, pre-Electra uses committee index. - config := gc.getBeaconConfig() - dataVersion, _ := config.ForkAtEpoch(config.EstimatedEpochAtSlot(slot)) - attData.Index = 0 - if dataVersion < spec.DataVersionElectra { - attData.Index = committeeIndex + // EIP-7549: Electra+ uses Index=0; pre-Electra uses committee index. Gloas (EIP-7732) instead keeps + // the BN-supplied payload-status index (0=EMPTY/1=FULL) — it is part of the signed AttestationData + // (SIP #94 §2 aggregation path), so the aggregate must be fetched under it. + // Decide the fork from the requested duty slot, not attData.Slot — the latter is what the + // beacon node returned (the same source the comment above warns "may return inconsistent values"), + // whereas the aggregate is for our duty's slot, which is authoritative. + cfg := gc.getBeaconConfig() + switch { + case cfg.IsGloasAtSlot(slot): + // keep attData.Index as the BN returned it + default: + version, _ := cfg.ForkAtEpoch(cfg.EstimatedEpochAtSlot(slot)) + attData.Index = 0 + if version < spec.DataVersionElectra { + attData.Index = committeeIndex + } } root, err = attData.HashTreeRoot() diff --git a/beacon/goclient/aggregator_test.go b/beacon/goclient/aggregator_test.go index b95e705e08..aefbc38f85 100644 --- a/beacon/goclient/aggregator_test.go +++ b/beacon/goclient/aggregator_test.go @@ -867,6 +867,35 @@ func mustHashTreeRoot(t *testing.T, data *phase0.AttestationData) phase0.Root { return root } +// Gloas keeps the BN-supplied payload-status index in the aggregation root (SIP #94 §2); Electra+ zeroes it. +func TestComputeAttestationDataRoot_GloasKeepsBNIndex(t *testing.T) { + t.Parallel() + + const gloasEpoch = 6 + cfg := *networkconfig.TestNetworkWithGloas(gloasEpoch).Beacon + slot := cfg.FirstSlotAtEpoch(gloasEpoch) + + attData := &phase0.AttestationData{ + Slot: slot, + Index: 1, // FULL — the BN payload-status index, which must be preserved + Source: &phase0.Checkpoint{Epoch: 1}, + Target: &phase0.Checkpoint{Epoch: 2}, + } + expectedRoot, err := attData.HashTreeRoot() + require.NoError(t, err) + + service := &aggregatorClientMock{} + service.AttestationDataFunc = func(_ context.Context, opts *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) { + require.Equal(t, slot, opts.Slot) + return &api.Response[*phase0.AttestationData]{Data: attData}, nil + } + + client := newAggregatorTestClient(&cfg, service) + root, err := client.computeAttestationDataRoot(t.Context(), slot, 7) + require.NoError(t, err) + require.Equal(t, expectedRoot, root) +} + func aggregatorTestBeaconConfig(genesisTime time.Time) networkconfig.Beacon { cfg := *networkconfig.TestNetwork.Beacon cfg.GenesisTime = genesisTime From 0b1887fd080e15cbf6e4920f8129511c211ef75e Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:48:59 +0300 Subject: [PATCH 058/150] gloas: refresh PTC duties on reorg / indices change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PTC handler's indicesChangeCh and reorgEventsCh cases were empty no-ops, and fetchDuties short-circuits on cache — so once an epoch was cached its duties were never re-fetched, leaving operators with stale PTC assignments across a reorg or mid-epoch validator-set change. SIP #94 §3 requires the authoritative post-change response to replace the cached epoch. Invalidate the cache on both channels (mirroring the ProposerPreferences handler's reEmitLookahead) so the next tick re-fetches. Add a unit test. --- operator/duties/ptc_attestation.go | 10 ++++++++++ operator/duties/ptc_attestation_test.go | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go index e390bb2d9a..1dcc23746a 100644 --- a/operator/duties/ptc_attestation.go +++ b/operator/duties/ptc_attestation.go @@ -66,11 +66,21 @@ func (h *PTCAttestationHandler) HandleDuties(ctx context.Context) { } case <-h.indicesChangeCh: + h.invalidateDuties("indices change") case <-h.reorgEventsCh: + h.invalidateDuties("reorg") } } } +// invalidateDuties drops the cached PTC duties so the next tick re-fetches them — after a reorg +// (new dependent_root) or a validator-set change, the authoritative response replaces the cached +// epoch rather than merging (SIP #94 §3). +func (h *PTCAttestationHandler) invalidateDuties(reason string) { + h.logger.Debug("🔀 re-fetching PTC duties on next tick", zap.String("reason", reason)) + clear(h.duties) +} + // fetchDuties fetches and caches an epoch's PTC duties once. func (h *PTCAttestationHandler) fetchDuties(ctx context.Context, epoch phase0.Epoch) { if _, cached := h.duties[epoch]; cached { diff --git a/operator/duties/ptc_attestation_test.go b/operator/duties/ptc_attestation_test.go index 09cd6af026..710712cd70 100644 --- a/operator/duties/ptc_attestation_test.go +++ b/operator/duties/ptc_attestation_test.go @@ -62,6 +62,18 @@ func TestPTCAttestationHandler_fetchDuties_cachesPerEpoch(t *testing.T) { require.Equal(t, idx, h.duties[epoch][dutySlot][0].ValidatorIndex) } +// A reorg or indices change drops the cached PTC duties so the next tick re-fetches them (SIP #94 §3). +func TestPTCAttestationHandler_invalidateDuties_clearsCache(t *testing.T) { + h := NewPTCAttestationHandler() + h.logger = zap.NewNop() + h.duties[100] = map[phase0.Slot][]*spectypes.ValidatorDuty{} + h.duties[101] = map[phase0.Slot][]*spectypes.ValidatorDuty{} + + h.invalidateDuties("test") + + require.Empty(t, h.duties) +} + // evictOutdated drops only epochs strictly before the current one. func TestPTCAttestationHandler_evictOutdated(t *testing.T) { h := NewPTCAttestationHandler() From d5f68a3011d1b852bc6acd0717030f7d215c3600 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 10:49:53 +0300 Subject: [PATCH 059/150] =?UTF-8?q?gloas:=20fix=20two=20stale=20=C2=A75/?= =?UTF-8?q?=C2=A76=20doc=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposer.go: triggerEnvelopeIfSelfBuild's comment said the starter is "a no-op until the controller wires it" — it is wired now (StartEnvelopeDuty), and the nil-guard only no-ops when the starter is unset (e.g. in tests). proposer_preferences.go: HandleDuties called reorg-driven re-emission a "deferred" refinement, but it's implemented (the reorg/indices-change cases call reEmitLookahead); only the publication-finality hold remains deferred. --- operator/duties/proposer_preferences.go | 4 ++-- protocol/v2/ssv/runner/proposer.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index 6483d0af48..77dc57dbe0 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -37,8 +37,8 @@ func (h *ProposerPreferencesHandler) WaitShutdown() {} // HandleDuties emits proposer-preferences duties across the proposer lookahead (current + next epoch, // MIN_SEED_LOOKAHEAD=1). In the epoch immediately before the Gloas fork it pre-emits the first Gloas -// epoch's preferences (SIP #94 §5) so builders have them before the fork. Reorg-driven re-emission and -// the publication-finality hold are deferred refinements. +// epoch's preferences (SIP #94 §5) so builders have them before the fork. Reorg/indices-change re-emission +// is handled below; the publication-finality hold is a deferred refinement. func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.logger.Info("starting duty handler") defer h.logger.Info("duty handler exited") diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 3dac4b40d2..98b3503f56 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -543,7 +543,7 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo // triggerEnvelopeIfSelfBuild starts the §6 envelope-signing duty for the slot when the decided block is // self-build — only then does the SSV cluster sign the envelope (external builders sign their own). The -// starter dispatches async (see startEnvelopeDuty); a no-op until the controller wires it. +// starter dispatches async (see startEnvelopeDuty); it no-ops when the starter is unset (e.g. in tests). func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(block *gloas.BeaconBlock, slot phase0.Slot) { if r.startEnvelopeDuty == nil { return From d4218bec3ac5b8947145d351de69a3899332f5b0 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 15:57:35 +0300 Subject: [PATCH 060/150] gloas: harden PTC message validation (lateness + duty-count cap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PTC role was wired into the role / partial-sig validation arms but missed two common_checks arms, so PTC messages got no lateness bound (default 0) and no per-epoch duty-count cap (default no-limit) — a spam-hardening gap before live use (SIP #94 §3). Add the arms: messageLateness gives PTC the same current-slot bound as the proposer / envelope / sync-contribution roles (a payload attestation fires at the 75% cutoff, so one for a past slot is a replay); dutyLimit caps at SlotsPerEpoch (a member signs at most one payload attestation per slot — validation is per-validator, so at most one per slot), mirroring the proposer-preferences / envelope arms. Tests for both. --- message/validation/common_checks.go | 6 ++--- message/validation/ptc_attester_test.go | 35 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 message/validation/ptc_attester_test.go diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 06c76ae47f..28cc5e92f6 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -61,7 +61,7 @@ func (mv *messageValidator) earlySlotAllowance(role spectypes.RunnerRole) time.D func (mv *messageValidator) messageLateness(slot phase0.Slot, role spectypes.RunnerRole, receivedAt time.Time) time.Duration { var ttl uint64 switch role { - case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder, ssvtypes.RoleSyncCommitteeContribution: + case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder, spectypes.RolePTCAttester, ssvtypes.RoleSyncCommitteeContribution: ttl = 1 + LateSlotAllowance case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: ttl = mv.maxStoredSlots() @@ -150,9 +150,9 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true - case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: + case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder, spectypes.RolePTCAttester: // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences (and likewise - // self-build envelopes) per epoch. + // self-build envelopes) per epoch; a PTC member likewise signs at most one payload attestation per slot. return mv.netCfg.SlotsPerEpoch, true default: diff --git a/message/validation/ptc_attester_test.go b/message/validation/ptc_attester_test.go new file mode 100644 index 0000000000..2092f56f13 --- /dev/null +++ b/message/validation/ptc_attester_test.go @@ -0,0 +1,35 @@ +package validation + +import ( + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/networkconfig" +) + +// A PTC member signs at most one payload attestation per slot → at most SlotsPerEpoch per epoch. +func TestDutyLimit_PTCAttester(t *testing.T) { + mv := &messageValidator{netCfg: networkconfig.TestNetwork} + msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RolePTCAttester) + + limit, ok := mv.dutyLimit(msgID, 0, nil) + require.True(t, ok) + require.Equal(t, mv.netCfg.SlotsPerEpoch, limit) +} + +// PTC fires at the slot's 75% cutoff (a current-slot duty): fine around its slot, late for a slot well behind. +func TestMessageLateness_PTCAttester(t *testing.T) { + netCfg := networkconfig.TestNetwork + mv := &messageValidator{netCfg: netCfg} + slot := phase0.Slot(1000) + + notLate := mv.messageLateness(slot, spectypes.RolePTCAttester, netCfg.SlotStartTime(slot)) + require.LessOrEqual(t, notLate, time.Duration(0)) + + late := mv.messageLateness(slot, spectypes.RolePTCAttester, netCfg.SlotStartTime(slot+100)) + require.Greater(t, late, time.Duration(0)) +} From 19bb6ef9c4197cd36594d8041d375690064f863b Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 16:40:41 +0300 Subject: [PATCH 061/150] gloas: add proposal build-source telemetry + envelope log slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T13/U6: add proposal.build_source — a counter splitting submitted Gloas proposals into self-build vs builder, keyed off the decided bid's builder_index. Scoped to Gloas: every operator sees the same decided bid, so the split is operator-agnostic, unlike the pre-Gloas Blinded flag which SSV's distributed submit would skew (non-leaders submit blinded regardless of source). Extract a shared selfBuild helper (also used by the envelope trigger). Also add the duty slot to the §6 envelope publish/skip logs, which had none. --- observability/attributes.go | 4 ++++ protocol/v2/ssv/runner/envelope.go | 5 +++-- protocol/v2/ssv/runner/observability.go | 17 +++++++++++++++++ protocol/v2/ssv/runner/proposer.go | 14 +++++++++----- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/observability/attributes.go b/observability/attributes.go index 5ad4a42dc2..98b93aacc4 100644 --- a/observability/attributes.go +++ b/observability/attributes.go @@ -64,6 +64,10 @@ func DutyOutcomeAttribute(outcome string) attribute.KeyValue { return attribute.String("ssv.validator.duty.outcome", outcome) } +func BuildSourceAttribute(source string) attribute.KeyValue { + return attribute.String("ssv.validator.duty.build_source", source) +} + func BeaconPeriodAttribute(period uint64) attribute.KeyValue { return attribute.KeyValue{ Key: "ssv.beacon.period", diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 4b136e1f58..9c331aefda 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -17,6 +17,7 @@ import ( "github.com/ssvlabs/ssv/ssvsigner/ekm" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/observability/log/fields" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" @@ -208,9 +209,9 @@ func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap. if err := r.GetBeaconNode().SubmitExecutionPayloadEnvelope(ctx, signed); err != nil { return fmt.Errorf("submit execution payload envelope: %w", err) } - logger.Info("✅ published execution payload envelope") + logger.Info("✅ published execution payload envelope", fields.Slot(cd.Duty.Slot)) } else { - logger.Debug("this operator did not build the decided envelope, skipping publication") + logger.Debug("this operator did not build the decided envelope, skipping publication", fields.Slot(cd.Duty.Slot)) } r.markDutySucceeded() diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 22562597eb..6b0439a4e4 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -124,6 +124,12 @@ var ( observability.InstrumentName(observabilityNamespace, "duty.outcome"), metric.WithUnit("{duty}"), metric.WithDescription("total number of concluded duties, by outcome"))) + + proposalBuildSourceCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "proposal.build_source"), + metric.WithUnit("{proposal}"), + metric.WithDescription("submitted Gloas block proposals by build source (self-build vs builder)"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -142,6 +148,17 @@ func recordDutyOutcome(ctx context.Context, role spectypes.RunnerRole, outcome d )) } +// recordProposalBuildSource counts a submitted Gloas proposal by build source — self-build +// (BUILDER_INDEX_SELF_BUILD) vs external builder. Gloas-only: the decided bid is the same for every +// operator, unlike the pre-Gloas Blinded flag, which the distributed submit skews. +func recordProposalBuildSource(ctx context.Context, localBuild bool) { + source := "builder" + if localBuild { + source = "local" + } + proposalBuildSourceCounter.Add(ctx, 1, metric.WithAttributes(observability.BuildSourceAttribute(source))) +} + func recordPreConsensusDuration(ctx context.Context, duration time.Duration, role spectypes.RunnerRole) { preConsensusDurationHistogram.Record(ctx, duration.Seconds(), metric.WithAttributes( diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 98b3503f56..75e01e7229 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -534,6 +534,7 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo recordFailedSubmission(ctx, spectypes.BNRoleProposer) finishErr = fmt.Errorf("submit gloas beacon block: %w", err) } else { + recordProposalBuildSource(ctx, selfBuild(block)) finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) } @@ -545,16 +546,19 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo // self-build — only then does the SSV cluster sign the envelope (external builders sign their own). The // starter dispatches async (see startEnvelopeDuty); it no-ops when the starter is unset (e.g. in tests). func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(block *gloas.BeaconBlock, slot phase0.Slot) { - if r.startEnvelopeDuty == nil { - return - } - bid := block.Body.SignedExecutionPayloadBid - if bid == nil || bid.Message == nil || bid.Message.BuilderIndex != gloas.BuilderIndexSelfBuild { + if r.startEnvelopeDuty == nil || !selfBuild(block) { return } r.startEnvelopeDuty(slot) } +// selfBuild reports whether the decided Gloas block commits to a self-built payload +// (BUILDER_INDEX_SELF_BUILD) rather than an external builder's bid. +func selfBuild(block *gloas.BeaconBlock) bool { + bid := block.Body.SignedExecutionPayloadBid + return bid != nil && bid.Message != nil && bid.Message.BuilderIndex == gloas.BuilderIndexSelfBuild +} + // recordDecidedBlockRoot stores the §4-decided block's root for the §6 envelope runner and its // value-check to read (SIP #94 §6). No-op when no envelope runner shares the store. func (r *ProposerRunner) recordDecidedBlockRoot(slot phase0.Slot, block *gloas.BeaconBlock) error { From eb5edd66f8402cea7efa2fda4f98a645ab4f33b9 Mon Sep 17 00:00:00 2001 From: iurii Date: Sat, 27 Jun 2026 18:14:48 +0300 Subject: [PATCH 062/150] gloas: gate PTC messages on a real duty assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the proposer/sync-committee pattern so the message validator can reject PTC partial signatures from a validator not on the slot's Payload Timeliness Committee — tightening the spam bound from the per-validator SlotsPerEpoch cap down to the validator's actual PTC slots. - dutystore: add a shared PTC store (Duties[gloas.PTCDuty]) plus EraseBefore and Clear primitives for per-tick eviction and reorg invalidation. - handler: write the shared store instead of a goroutine-local map, recording every participating validator's duty in both operator and exporter modes (InCommittee marks this node's own for execution), and pre-fetch on startup via HandleInitialDuties. - validateBeaconDuty: add an IsEpochSet-tolerant RolePTCAttester arm. --- message/validation/common_checks.go | 11 +++ message/validation/ptc_attester_test.go | 26 +++++ operator/duties/dutystore/duties.go | 25 ++++- operator/duties/dutystore/duties_test.go | 30 ++++++ operator/duties/dutystore/store.go | 4 + operator/duties/ptc_attestation.go | 106 +++++++++++++++------ operator/duties/ptc_attestation_test.go | 116 +++++++++++++++++------ operator/duties/scheduler.go | 2 +- 8 files changed, 261 insertions(+), 59 deletions(-) diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 28cc5e92f6..2d9e0e10e8 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -207,6 +207,17 @@ func (mv *messageValidator) validateBeaconDuty( } } + // Rule: For a PTC attestation message, require a real PTC assignment for the validator at the slot, + // but only once the slot's epoch is fetched — PTC duties are fetched per epoch, so a not-yet-fetched + // epoch (e.g. at startup) must be tolerated rather than rejected. + if role == spectypes.RolePTCAttester { + // Non-committee roles always have one validator index. + validatorIndex := indices[0] + if mv.dutyStore.PTC.IsEpochSet(epoch) && mv.dutyStore.PTC.ValidatorDuty(epoch, slot, validatorIndex) == nil { + return ErrNoDuty + } + } + // Committee roles (RoleCommittee and RoleAggregatorCommittee) are intentionally not // per-validator duty-asserted here. As elsewhere in committee-role validation, we do not assume // operators are synced on each other's validator sets (see knowledge-base#2), so asserting a diff --git a/message/validation/ptc_attester_test.go b/message/validation/ptc_attester_test.go index 2092f56f13..e0f829d1a8 100644 --- a/message/validation/ptc_attester_test.go +++ b/message/validation/ptc_attester_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) // A PTC member signs at most one payload attestation per slot → at most SlotsPerEpoch per epoch. @@ -33,3 +35,27 @@ func TestMessageLateness_PTCAttester(t *testing.T) { late := mv.messageLateness(slot, spectypes.RolePTCAttester, netCfg.SlotStartTime(slot+100)) require.Greater(t, late, time.Duration(0)) } + +// A PTC attestation message must reference a real PTC assignment for the validator once the slot's +// epoch is fetched; an unfetched epoch is tolerated (the duty fetch may be in flight). +func TestValidateBeaconDuty_PTCAttesterRequiresAssignment(t *testing.T) { + netCfg := networkconfig.TestNetwork + const epoch = phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + slot := phase0.Slot(uint64(epoch)*netCfg.SlotsPerEpoch + 3) + + ds := dutystore.New() + ds.PTC.Set(epoch, []dutystore.StoreDuty[gloas.PTCDuty]{ + {Slot: slot, ValidatorIndex: idx, Duty: &gloas.PTCDuty{Slot: slot, ValidatorIndex: idx}, InCommittee: true}, + }) + mv := &messageValidator{netCfg: netCfg, dutyStore: ds} + + indices := []phase0.ValidatorIndex{idx} + // Assigned PTC slot → accepted. + require.NoError(t, mv.validateBeaconDuty(spectypes.RolePTCAttester, slot, indices, false)) + // Same (fetched) epoch, unassigned slot → rejected. + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RolePTCAttester, slot+1, indices, false), ErrNoDuty) + // Unfetched epoch → tolerated. + unfetched := phase0.Slot(uint64(epoch+10) * netCfg.SlotsPerEpoch) + require.NoError(t, mv.validateBeaconDuty(spectypes.RolePTCAttester, unfetched, indices, false)) +} diff --git a/operator/duties/dutystore/duties.go b/operator/duties/dutystore/duties.go index 514c3cc48a..e888897a9c 100644 --- a/operator/duties/dutystore/duties.go +++ b/operator/duties/dutystore/duties.go @@ -5,10 +5,12 @@ import ( eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type Duty interface { - eth2apiv1.AttesterDuty | eth2apiv1.ProposerDuty + eth2apiv1.AttesterDuty | eth2apiv1.ProposerDuty | gloas.PTCDuty } type StoreDuty[D Duty] struct { @@ -118,6 +120,27 @@ func (d *Duties[D]) EraseEpochData(epoch phase0.Epoch) { delete(d.m, epoch) } +// EraseBefore drops every cached epoch earlier than the given one, bounding the per-epoch cache. +func (d *Duties[D]) EraseBefore(epoch phase0.Epoch) { + d.mu.Lock() + defer d.mu.Unlock() + + for cached := range d.m { + if cached < epoch { + delete(d.m, cached) + } + } +} + +// Clear drops every cached epoch. Used when a refresh must replace the whole cache rather than +// merge into it — e.g. PTC duties after a reorg or validator-set change (SIP #94 §3). +func (d *Duties[D]) Clear() { + d.mu.Lock() + defer d.mu.Unlock() + + d.m = make(map[phase0.Epoch]map[phase0.Slot]map[phase0.ValidatorIndex]StoreDuty[D]) +} + func (d *Duties[D]) IsEpochSet(epoch phase0.Epoch) bool { d.mu.RLock() defer d.mu.RUnlock() diff --git a/operator/duties/dutystore/duties_test.go b/operator/duties/dutystore/duties_test.go index 7a9fa8130a..53354012ce 100644 --- a/operator/duties/dutystore/duties_test.go +++ b/operator/duties/dutystore/duties_test.go @@ -59,6 +59,36 @@ func TestDutiesEraseEpochData(t *testing.T) { assert.Nil(t, duties.SlotIndices(epoch, 10)) } +func TestDutiesEraseBefore(t *testing.T) { + duties := NewDuties[eth2apiv1.ProposerDuty]() + for _, epoch := range []phase0.Epoch{4, 5, 6} { + duties.Set(epoch, []StoreDuty[eth2apiv1.ProposerDuty]{ + {Slot: 10, ValidatorIndex: 1, Duty: ð2apiv1.ProposerDuty{}}, + }) + } + + duties.EraseBefore(5) + + assert.False(t, duties.IsEpochSet(4)) + assert.True(t, duties.IsEpochSet(5)) + assert.True(t, duties.IsEpochSet(6)) +} + +func TestDutiesClear(t *testing.T) { + duties := NewDuties[eth2apiv1.ProposerDuty]() + for _, epoch := range []phase0.Epoch{4, 5, 6} { + duties.Set(epoch, []StoreDuty[eth2apiv1.ProposerDuty]{ + {Slot: 10, ValidatorIndex: 1, Duty: ð2apiv1.ProposerDuty{}}, + }) + } + + duties.Clear() + + for _, epoch := range []phase0.Epoch{4, 5, 6} { + assert.False(t, duties.IsEpochSet(epoch)) + } +} + func TestStoreDutyTypesUseIndependentLocks(t *testing.T) { epoch := phase0.Epoch(8) slot := phase0.Slot(64) diff --git a/operator/duties/dutystore/store.go b/operator/duties/dutystore/store.go index 1b1ff4032e..2c8f8bc0d7 100644 --- a/operator/duties/dutystore/store.go +++ b/operator/duties/dutystore/store.go @@ -2,11 +2,14 @@ package dutystore import ( eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type Store struct { Attester *Duties[eth2apiv1.AttesterDuty] Proposer *Duties[eth2apiv1.ProposerDuty] + PTC *Duties[gloas.PTCDuty] SyncCommittee *SyncCommitteeDuties VoluntaryExit *VoluntaryExitDuties } @@ -15,6 +18,7 @@ func New() *Store { return &Store{ Attester: NewDuties[eth2apiv1.AttesterDuty](), Proposer: NewDuties[eth2apiv1.ProposerDuty](), + PTC: NewDuties[gloas.PTCDuty](), SyncCommittee: NewSyncCommitteeDuties(), VoluntaryExit: NewVoluntaryExit(), } diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go index 1dcc23746a..abdf3b59a3 100644 --- a/operator/duties/ptc_attestation.go +++ b/operator/duties/ptc_attestation.go @@ -9,23 +9,30 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/observability/log/fields" + "github.com/ssvlabs/ssv/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) // PTCAttestationHandler schedules the Gloas (ePBS) Payload Timeliness Committee attestation duty -// (SIP #94 §3): it fetches PTC duties per epoch and, for each slot holding one, executes it at the -// 75%-of-slot cutoff so the runner observes payload presence at that point and runs its -// partial-signature round in the otherwise-free [75%, 100%] window. +// (SIP #94 §3): each epoch it fetches the PTC assignments and, for each slot holding one of this +// node's duties, executes it at the 75%-of-slot cutoff so the runner observes payload presence at +// that point and runs its partial-signature round in the otherwise-free [75%, 100%] window. +// +// Like the proposer and sync-committee handlers, it records every participating validator's duty +// (not just this node's) in the shared duty store — so the message validator can reject PTC messages +// from validators with no such assignment — and runs in both operator and exporter modes. InCommittee +// marks this node's own duties, which only operator mode executes. type PTCAttestationHandler struct { baseHandler - // duties caches fetched duties as ready-to-execute ValidatorDuties, keyed by epoch then slot. - // Accessed only from the HandleDuties goroutine. - duties map[phase0.Epoch]map[phase0.Slot][]*spectypes.ValidatorDuty + duties *dutystore.Duties[gloas.PTCDuty] + exporterMode bool } -func NewPTCAttestationHandler() *PTCAttestationHandler { +func NewPTCAttestationHandler(duties *dutystore.Duties[gloas.PTCDuty], exporterMode bool) *PTCAttestationHandler { return &PTCAttestationHandler{ - duties: map[phase0.Epoch]map[phase0.Slot][]*spectypes.ValidatorDuty{}, + duties: duties, + exporterMode: exporterMode, } } @@ -59,10 +66,18 @@ func (h *PTCAttestationHandler) HandleDuties(ctx context.Context) { if h.shouldFetchNextEpoch(slot) { h.fetchDuties(ctx, epoch+1) } - h.evictOutdated(epoch) + h.duties.EraseBefore(epoch) - if duties := h.duties[epoch][slot]; len(duties) > 0 { - h.scheduleExecution(ctx, slot, duties) + // Exporter records duties for message validation but does not execute them. + if h.exporterMode { + continue + } + if ptcDuties := h.duties.CommitteeSlotDuties(epoch, slot); len(ptcDuties) > 0 { + specDuties := make([]*spectypes.ValidatorDuty, 0, len(ptcDuties)) + for _, d := range ptcDuties { + specDuties = append(specDuties, h.toSpecDuty(d)) + } + h.scheduleExecution(ctx, slot, specDuties) } case <-h.indicesChangeCh: @@ -73,41 +88,72 @@ func (h *PTCAttestationHandler) HandleDuties(ctx context.Context) { } } +// HandleInitialDuties fetches the current epoch's PTC duties on startup — and the next epoch's near a +// boundary, so the ticker can't miss the rollover — populating the store before the first tick so the +// message validator can check assignments right away. +func (h *PTCAttestationHandler) HandleInitialDuties(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, h.netCfg.SlotDuration) + defer cancel() + + slot := h.netCfg.EstimatedCurrentSlot() + epoch := h.netCfg.EstimatedEpochAtSlot(slot) + if !h.netCfg.IsGloas(epoch) { + return + } + + h.fetchDuties(ctx, epoch) + if h.shouldFetchNextEpoch(slot) { + h.fetchDuties(ctx, epoch+1) + } +} + // invalidateDuties drops the cached PTC duties so the next tick re-fetches them — after a reorg -// (new dependent_root) or a validator-set change, the authoritative response replaces the cached -// epoch rather than merging (SIP #94 §3). +// (new dependent_root) or a validator-set change the authoritative response replaces the cached +// epochs rather than merging (SIP #94 §3). func (h *PTCAttestationHandler) invalidateDuties(reason string) { h.logger.Debug("🔀 re-fetching PTC duties on next tick", zap.String("reason", reason)) - clear(h.duties) + h.duties.Clear() } -// fetchDuties fetches and caches an epoch's PTC duties once. +// fetchDuties records an epoch's PTC duties in the shared store once: every participating validator's +// duty, with this node's own marked InCommittee for execution. func (h *PTCAttestationHandler) fetchDuties(ctx context.Context, epoch phase0.Epoch) { - if _, cached := h.duties[epoch]; cached { + if h.duties.IsEpochSet(epoch) { return } - indices := h.selfParticipatingIndices(epoch) - if len(indices) == 0 { + var eligible []phase0.ValidatorIndex + for _, share := range h.validatorProvider.Validators() { + if share.IsParticipating(h.netCfg.Beacon, epoch) { + eligible = append(eligible, share.ValidatorIndex) + } + } + if len(eligible) == 0 { return } - ptcDuties, err := h.beaconNode.PayloadAttestationDuties(ctx, epoch, indices) + ptcDuties, err := h.beaconNode.PayloadAttestationDuties(ctx, epoch, eligible) if err != nil { h.logger.Warn("failed to fetch PTC duties", fields.Epoch(epoch), zap.Error(err)) return } - bySlot := make(map[phase0.Slot][]*spectypes.ValidatorDuty) + self := make(map[phase0.ValidatorIndex]struct{}) + for _, idx := range h.selfParticipatingIndices(epoch) { + self[idx] = struct{}{} + } + + storeDuties := make([]dutystore.StoreDuty[gloas.PTCDuty], 0, len(ptcDuties)) for _, d := range ptcDuties { - bySlot[d.Slot] = append(bySlot[d.Slot], &spectypes.ValidatorDuty{ - Type: spectypes.BNRolePTCAttester, - PubKey: d.PubKey, - ValidatorIndex: d.ValidatorIndex, + _, inCommittee := self[d.ValidatorIndex] + storeDuties = append(storeDuties, dutystore.StoreDuty[gloas.PTCDuty]{ Slot: d.Slot, + ValidatorIndex: d.ValidatorIndex, + Duty: d, + InCommittee: inCommittee, }) } - h.duties[epoch] = bySlot + h.duties.Set(epoch, storeDuties) h.logger.Debug("fetched PTC duties", fields.Epoch(epoch), zap.Int("duties", len(ptcDuties))) } @@ -121,7 +167,11 @@ func (h *PTCAttestationHandler) scheduleExecution(ctx context.Context, slot phas }) } -// evictOutdated drops cached duties for epochs before the current one. -func (h *PTCAttestationHandler) evictOutdated(currentEpoch phase0.Epoch) { - evictEpochsBefore(h.duties, currentEpoch) +func (h *PTCAttestationHandler) toSpecDuty(duty *gloas.PTCDuty) *spectypes.ValidatorDuty { + return &spectypes.ValidatorDuty{ + Type: spectypes.BNRolePTCAttester, + PubKey: duty.PubKey, + ValidatorIndex: duty.ValidatorIndex, + Slot: duty.Slot, + } } diff --git a/operator/duties/ptc_attestation_test.go b/operator/duties/ptc_attestation_test.go index 710712cd70..335040c24e 100644 --- a/operator/duties/ptc_attestation_test.go +++ b/operator/duties/ptc_attestation_test.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/operator/duties/dutystore" "github.com/ssvlabs/ssv/protocol/v2/types" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) @@ -28,64 +29,121 @@ func (c *captureExecutor) ExecuteDuties(_ context.Context, duties []*spectypes.V func (c *captureExecutor) ExecuteCommitteeDuties(context.Context, committeeDutiesMap, time.Time) {} -// fetchDuties caches an epoch's duties on first fetch and short-circuits on repeat — the Times(1) -// expectations on both mocks fail if the second call re-fetches. +// fetchDuties records an epoch's duties once and short-circuits on repeat — the Times(1) +// expectations fail if the second call re-fetches. func TestPTCAttestationHandler_fetchDuties_cachesPerEpoch(t *testing.T) { ctrl := gomock.NewController(t) epoch := phase0.Epoch(5) idx := phase0.ValidatorIndex(7) dutySlot := phase0.Slot(60) - pk := phase0.BLSPubKey{1, 2, 3} vp := NewMockValidatorProvider(ctrl) - vp.EXPECT().SelfParticipatingValidators(epoch). - Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}). - Times(1) + vp.EXPECT().Validators().Return([]*types.SSVShare{activeShare(idx)}).Times(1) + vp.EXPECT().SelfParticipatingValidators(epoch).Return([]*types.SSVShare{activeShare(idx)}).Times(1) bn := NewMockBeaconNode(ctrl) bn.EXPECT().PayloadAttestationDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). - Return([]*gloas.PTCDuty{{PubKey: pk, ValidatorIndex: idx, Slot: dutySlot}}, nil). + Return([]*gloas.PTCDuty{{ValidatorIndex: idx, Slot: dutySlot}}, nil). Times(1) - h := NewPTCAttestationHandler() + store := dutystore.NewDuties[gloas.PTCDuty]() + h := NewPTCAttestationHandler(store, false) h.logger = zap.NewNop() + h.netCfg = networkconfig.TestNetwork h.validatorProvider = vp h.beaconNode = bn h.fetchDuties(context.Background(), epoch) h.fetchDuties(context.Background(), epoch) - require.Contains(t, h.duties, epoch) - require.Len(t, h.duties[epoch][dutySlot], 1) - require.Equal(t, spectypes.BNRolePTCAttester, h.duties[epoch][dutySlot][0].Type) - require.Equal(t, idx, h.duties[epoch][dutySlot][0].ValidatorIndex) + require.True(t, store.IsEpochSet(epoch)) + require.NotNil(t, store.ValidatorDuty(epoch, dutySlot, idx)) } -// A reorg or indices change drops the cached PTC duties so the next tick re-fetches them (SIP #94 §3). -func TestPTCAttestationHandler_invalidateDuties_clearsCache(t *testing.T) { - h := NewPTCAttestationHandler() +// fetchDuties records every participating validator's duty so the message validator can check +// assignments, marking only this node's own InCommittee (executable). +func TestPTCAttestationHandler_fetchDuties_recordsAllMarksSelf(t *testing.T) { + ctrl := gomock.NewController(t) + + epoch := phase0.Epoch(5) + selfIdx := phase0.ValidatorIndex(7) + otherIdx := phase0.ValidatorIndex(8) + dutySlot := phase0.Slot(60) + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().Validators().Return([]*types.SSVShare{activeShare(selfIdx), activeShare(otherIdx)}) + vp.EXPECT().SelfParticipatingValidators(epoch).Return([]*types.SSVShare{activeShare(selfIdx)}) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().PayloadAttestationDuties(gomock.Any(), epoch, gomock.Any()). + Return([]*gloas.PTCDuty{ + {ValidatorIndex: selfIdx, Slot: dutySlot}, + {ValidatorIndex: otherIdx, Slot: dutySlot}, + }, nil) + + store := dutystore.NewDuties[gloas.PTCDuty]() + h := NewPTCAttestationHandler(store, false) h.logger = zap.NewNop() - h.duties[100] = map[phase0.Slot][]*spectypes.ValidatorDuty{} - h.duties[101] = map[phase0.Slot][]*spectypes.ValidatorDuty{} + h.netCfg = networkconfig.TestNetwork + h.validatorProvider = vp + h.beaconNode = bn - h.invalidateDuties("test") + h.fetchDuties(context.Background(), epoch) - require.Empty(t, h.duties) + // Both validators are recorded so the message validator can check assignments... + require.NotNil(t, store.ValidatorDuty(epoch, dutySlot, selfIdx)) + require.NotNil(t, store.ValidatorDuty(epoch, dutySlot, otherIdx)) + // ...but only this node's own duty is executable. + executable := store.CommitteeSlotDuties(epoch, dutySlot) + require.Len(t, executable, 1) + require.Equal(t, selfIdx, executable[0].ValidatorIndex) } -// evictOutdated drops only epochs strictly before the current one. -func TestPTCAttestationHandler_evictOutdated(t *testing.T) { - h := NewPTCAttestationHandler() - for _, e := range []phase0.Epoch{4, 5, 6} { - h.duties[e] = map[phase0.Slot][]*spectypes.ValidatorDuty{} +// HandleInitialDuties pre-fetches the current epoch on startup, so the store is populated before the +// first tick. +func TestPTCAttestationHandler_HandleInitialDuties_prefetchesCurrentEpoch(t *testing.T) { + ctrl := gomock.NewController(t) + + netCfg := networkconfig.TestNetworkWithGloas(0) // Gloas from genesis. + idx := phase0.ValidatorIndex(7) + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().Validators().Return([]*types.SSVShare{activeShare(idx)}).AnyTimes() + vp.EXPECT().SelfParticipatingValidators(gomock.Any()).Return([]*types.SSVShare{activeShare(idx)}).AnyTimes() + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().PayloadAttestationDuties(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*gloas.PTCDuty{{ValidatorIndex: idx}}, nil).AnyTimes() + + store := dutystore.NewDuties[gloas.PTCDuty]() + h := NewPTCAttestationHandler(store, false) + h.logger = zap.NewNop() + h.netCfg = netCfg + h.validatorProvider = vp + h.beaconNode = bn + + h.HandleInitialDuties(context.Background()) + + require.True(t, store.IsEpochSet(netCfg.EstimatedCurrentEpoch())) +} + +// A reorg or indices change drops the cached PTC duties so the next tick re-fetches them (SIP #94 §3). +func TestPTCAttestationHandler_invalidateDuties_clearsCache(t *testing.T) { + store := dutystore.NewDuties[gloas.PTCDuty]() + for _, epoch := range []phase0.Epoch{100, 101} { + store.Set(epoch, []dutystore.StoreDuty[gloas.PTCDuty]{ + {Slot: 1, ValidatorIndex: 1, Duty: &gloas.PTCDuty{}}, + }) } - h.evictOutdated(5) + h := NewPTCAttestationHandler(store, false) + h.logger = zap.NewNop() + + h.invalidateDuties("test") - require.NotContains(t, h.duties, phase0.Epoch(4)) - require.Contains(t, h.duties, phase0.Epoch(5)) - require.Contains(t, h.duties, phase0.Epoch(6)) + require.False(t, store.IsEpochSet(100)) + require.False(t, store.IsEpochSet(101)) } // scheduleExecution fires the duty at the 75%-of-slot cutoff, not before. @@ -99,7 +157,7 @@ func TestPTCAttestationHandler_scheduleExecution_firesAtCutoff(t *testing.T) { netCfg.Beacon = &beaconCfg executed := make(chan []*spectypes.ValidatorDuty, 1) - h := NewPTCAttestationHandler() + h := NewPTCAttestationHandler(dutystore.NewDuties[gloas.PTCDuty](), false) h.logger = zap.NewNop() h.netCfg = &netCfg h.dutiesExecutor = &captureExecutor{executed: executed} diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index c6ebe5a6e2..cd230211f8 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -173,6 +173,7 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler { NewAttesterHandler(dutyStore.Attester, opts.ExporterMode), NewProposerHandler(dutyStore.Proposer, opts.ExporterMode), NewSyncCommitteeHandler(dutyStore.SyncCommittee, opts.ExporterMode), + NewPTCAttestationHandler(dutyStore.PTC, opts.ExporterMode), ) // These handlers only execute duties and are not needed in exporter mode. if !opts.ExporterMode { @@ -181,7 +182,6 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler { NewCommitteeHandler(dutyStore.Attester, dutyStore.SyncCommittee, true), NewValidatorRegistrationHandler(opts.ValidatorRegistrationCh), NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh), - NewPTCAttestationHandler(), NewProposerPreferencesHandler(), ) } From 95aaeade35e13e68ee709a9424d05f731e221ed9 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 00:47:33 +0300 Subject: [PATCH 063/150] gloas: retime QBFT round timer to IntervalDuration; add ProposerDelayEPBS ePBS shortens the slot's duty intervals (thirds -> quarters, via IntervalDuration), so proposer/attester timing that was hardcoded to thirds has to track it. - QBFT round timer: round1HeadStart now derives from IntervalDuration (committee 1x, aggregator/sync 2x) instead of slotDuration/3, so the head starts follow the retimed attestation/aggregate deadlines under Gloas. RoundTimeout and EstimatedRoundAt (plus the message-validation round-spread check) pass IntervalDuration(slot). Pre-Gloas behavior is byte-identical (interval = slotDuration/3). QuickTimeout stays a fixed 2s round-trip budget -- under the tighter Gloas deadline the proposer is effectively round-1-must-succeed, a deliberate choice pending devnet round-trip data. - ProposerDelayEPBS: a new fork-gated knob. ProposerDelay (and its dangerous override) applies pre-Gloas only; ProposerDelayEPBS applies from the Gloas fork on, hard-capped at 1s with no override and default 0 -- the safe ranges differ under the tighter deadline. Threaded node config -> controller -> runner. - EXTERNAL_BUILDERS.md: add an ePBS forward-pointer (in-protocol PBS supersedes the external-builder flow at Gloas). --- cli/operator/config.go | 8 +++ cli/operator/config_completeness_test.go | 2 +- cli/operator/config_test.go | 29 +++++++++ cli/operator/node.go | 1 + cli/operator/testdata/defaults.golden.json | 1 + config/config.example.yaml | 5 ++ docs/EXTERNAL_BUILDERS.md | 7 +++ message/validation/consensus_validation.go | 6 +- .../validation/consensus_validation_test.go | 2 +- message/validation/validation_test.go | 2 +- operator/validator/controller.go | 3 + protocol/v2/qbft/roundtimer/timer.go | 60 +++++++++---------- protocol/v2/qbft/roundtimer/timer_test.go | 44 ++++++++++---- protocol/v2/ssv/runner/proposer.go | 27 ++++++--- protocol/v2/ssv/runner/proposer_test.go | 17 ++++++ protocol/v2/ssv/validator/opts.go | 3 + 16 files changed, 162 insertions(+), 55 deletions(-) diff --git a/cli/operator/config.go b/cli/operator/config.go index 76024d03ce..8c5e03e431 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -48,6 +48,7 @@ type config struct { Graffiti string `yaml:"Graffiti" env:"GRAFFITI" env-description:"Custom graffiti for block proposals"` ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#getting-started-with-mev-configuration for detailed instructions on how to use it."` AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1s (dangerous, may cause missed block proposals)"` + ProposerDelayEPBS time.Duration `yaml:"ProposerDelayEPBS" env:"PROPOSER_DELAY_EPBS" env-description:"Post-ePBS (Gloas) counterpart of ProposerDelay, applied from the Gloas fork on (ProposerDelay applies before it). Hard-capped at 1s with no dangerous override. Default 0 (opt-in)."` OperatorPrivateKey string `yaml:"OperatorPrivateKey" env:"OPERATOR_KEY" env-description:"Operator private key for contract event decryption"` MetricsAPIPort int `yaml:"MetricsAPIPort" env:"METRICS_API_PORT" env-description:"Port for metrics API server"` EnableTraces bool `yaml:"EnableTraces" env:"ENABLE_TRACES" env-description:"Enable Open Telemetry traces"` @@ -157,6 +158,13 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { zap.Duration("max_safe_proposer_delay", maxSafeProposerDelay)) } + // ProposerDelayEPBS applies from the Gloas fork on and has no dangerous-override escape hatch: + // the post-ePBS proposal deadline is tighter, so the cap is enforced unconditionally. + if c.ProposerDelayEPBS > maxSafeProposerDelay { + return resolved{}, fmt.Errorf("ProposerDelayEPBS value %v exceeds maximum safe delay of %v (no override is available for the post-ePBS delay)", + c.ProposerDelayEPBS, maxSafeProposerDelay) + } + // Resolve the operating mode last so a doubly-misconfigured node still surfaces the signing // or proposer-delay error first. m, err := resolveMode(c.ExporterOptions) diff --git a/cli/operator/config_completeness_test.go b/cli/operator/config_completeness_test.go index dd84541136..0f39e0b6dd 100644 --- a/cli/operator/config_completeness_test.go +++ b/cli/operator/config_completeness_test.go @@ -30,7 +30,7 @@ func Test_config_defaults_complete(t *testing.T) { "SSVSigner.KeystorePasswordFile", "SSVSigner.ServerCertFile", "p2p.Bootnodes", "p2p.HostAddress", "p2p.HostDNS", "p2p.Subnets", "p2p.TrustedPeers", "ssv.CustomDomainType", "ssv.CustomNetwork", // optional ports / sizes / timeouts (0 = disabled, or a library/runtime default applies later) - "MetricsAPIPort", "SSVAPIPort", "WebSocketAPIPort", "ProposerDelay", + "MetricsAPIPort", "SSVAPIPort", "WebSocketAPIPort", "ProposerDelay", "ProposerDelayEPBS", "eth2.CommonTimeout", "eth2.LongTimeout", "eth2.ProposalSoftTimeout", "p2p.PubsubMsgCacheTTL", "p2p.PubsubOutQueueSize", "p2p.PubsubValidateThrottle", "p2p.PubsubValidationQueueSize", "ssv.ValidatorOptions.ExperimentalGasLimit", diff --git a/cli/operator/config_test.go b/cli/operator/config_test.go index e9370d1e8a..54daea8287 100644 --- a/cli/operator/config_test.go +++ b/cli/operator/config_test.go @@ -160,6 +160,35 @@ func Test_resolveAndValidate_proposerDelay(t *testing.T) { }) } +// Test_resolveAndValidate_proposerDelayEPBS covers the post-ePBS delay cap: unlike ProposerDelay, +// ProposerDelayEPBS has no dangerous-override escape hatch, so exceeding the cap always errors. +func Test_resolveAndValidate_proposerDelayEPBS(t *testing.T) { + t.Run("exceeding the cap always errors (no override)", func(t *testing.T) { + for _, delay := range []time.Duration{1001 * time.Millisecond, 2000 * time.Millisecond} { + t.Run(delay.String(), func(t *testing.T) { + c := config{} + c.OperatorPrivateKey = testOperatorKey + c.ProposerDelayEPBS = delay + c.AllowDangerousProposerDelay = true // must NOT help: ProposerDelayEPBS has no override + + _, err := c.resolveAndValidate(zap.NewNop()) + require.Error(t, err) + require.Contains(t, err.Error(), "ProposerDelayEPBS value") + require.Contains(t, err.Error(), "no override") + }) + } + }) + + t.Run("at the cap passes", func(t *testing.T) { + c := config{} + c.OperatorPrivateKey = testOperatorKey + c.ProposerDelayEPBS = 1000 * time.Millisecond + + _, err := c.resolveAndValidate(zap.NewNop()) + require.NoError(t, err) + }) +} + // Test_resolveAndValidate_signingErrorContext verifies resolveAndValidate enriches a signing // error with the configured-source context, without exposing the private key value. func Test_resolveAndValidate_signingErrorContext(t *testing.T) { diff --git a/cli/operator/node.go b/cli/operator/node.go index 218dfd45b3..1d72013789 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -441,6 +441,7 @@ func newNode( valOpts.StorageMap = storageMap valOpts.Graffiti = []byte(cfg.Graffiti) valOpts.ProposerDelay = cfg.ProposerDelay + valOpts.ProposerDelayEPBS = cfg.ProposerDelayEPBS valOpts.ValidatorSyncer = metadataSyncer valOpts.ExporterMode = res.isExporter() valOpts.MessageTraceHandler = messageTraceHandler diff --git a/cli/operator/testdata/defaults.golden.json b/cli/operator/testdata/defaults.golden.json index 6620277a90..5e92971682 100644 --- a/cli/operator/testdata/defaults.golden.json +++ b/cli/operator/testdata/defaults.golden.json @@ -11,6 +11,7 @@ "NetworkPrivateKey": "", "OperatorPrivateKey": "", "ProposerDelay": "0s", + "ProposerDelayEPBS": "0s", "SSVAPIAddress": "", "SSVAPIPort": "0", "SSVSigner.Endpoint": "", diff --git a/config/config.example.yaml b/config/config.example.yaml index c16811ef60..2d794657fe 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -53,6 +53,11 @@ OperatorPrivateKey: # Only set to true if you understand the risks and have carefully read the MEV documentation. # AllowDangerousProposerDelay: false +# Post-ePBS (Gloas) counterpart of ProposerDelay, applied from the Gloas fork on (ProposerDelay applies +# before it). Hard-capped at 1s with no dangerous override. Default is 0 (opt-in). Gloas is not active on +# mainnet yet; tune only after validating on a Gloas network. See docs/MEV_CONSIDERATIONS.md. +# ProposerDelayEPBS: 0 + # This enables monitoring at the specified port, see https://github.com/ssvlabs/ssv/tree/main/monitoring MetricsAPIPort: 15000 diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index 1e74ae179f..b842f357f1 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -1,5 +1,12 @@ # Builder proposals +> **ePBS / Gloas (EIP-7732).** This page describes the pre-Gloas external-builder flow — out-of-protocol +> PBS via MEV-Boost/commit-boost and relays. At the Gloas fork, in-protocol (enshrined) PBS supersedes it: +> the proposer publishes a block committing to a builder's *bid* instead of fetching a blinded block from a +> relay, the builder reveals the execution payload separately, and a Payload Timeliness Committee attests to +> its on-time arrival. SSV runs these new duties automatically, with no operator configuration. Gloas is not +> active on Ethereum mainnet yet (devnets only); this page will be revised as ePBS approaches mainnet. + ## How to use 1. Configure your beacon node to use an external builder diff --git a/message/validation/consensus_validation.go b/message/validation/consensus_validation.go index 20efbbe94f..aed850503c 100644 --- a/message/validation/consensus_validation.go +++ b/message/validation/consensus_validation.go @@ -439,8 +439,8 @@ func (mv *messageValidator) maxRound(role spectypes.RunnerRole) (specqbft.Round, } } -func (mv *messageValidator) estimatedRoundAt(role spectypes.RunnerRole, timeIntoSlot time.Duration) (specqbft.Round, error) { - return roundtimer.EstimatedRoundAt(role, mv.netCfg.SlotDuration, timeIntoSlot) +func (mv *messageValidator) estimatedRoundAt(role spectypes.RunnerRole, slot phase0.Slot, timeIntoSlot time.Duration) (specqbft.Round, error) { + return roundtimer.EstimatedRoundAt(role, mv.netCfg.IntervalDuration(slot), timeIntoSlot) } func (mv *messageValidator) validConsensusMsgType(msgType specqbft.MessageType) bool { @@ -548,7 +548,7 @@ func (mv *messageValidator) roundBelongsToAllowedSpread( slotStartTime := mv.netCfg.SlotStartTime(phase0.Slot(consensusMessage.Height)) timeIntoSlot := receivedAt.Sub(slotStartTime) - estimatedRoundMsgReceivedAt, err := mv.estimatedRoundAt(role, timeIntoSlot) + estimatedRoundMsgReceivedAt, err := mv.estimatedRoundAt(role, phase0.Slot(consensusMessage.Height), timeIntoSlot) if err != nil { return err } diff --git a/message/validation/consensus_validation_test.go b/message/validation/consensus_validation_test.go index 0fab5ed387..81365ef3b2 100644 --- a/message/validation/consensus_validation_test.go +++ b/message/validation/consensus_validation_test.go @@ -140,7 +140,7 @@ func TestMessageValidator_currentEstimatedRound(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { mv := &messageValidator{netCfg: netCfg} - got, err := mv.estimatedRoundAt(tc.role, tc.timeIntoSlot) + got, err := mv.estimatedRoundAt(tc.role, 0, tc.timeIntoSlot) require.NoError(t, err) require.Equal(t, tc.want, got) }) diff --git a/message/validation/validation_test.go b/message/validation/validation_test.go index 815826faf3..a880df4e9e 100644 --- a/message/validation/validation_test.go +++ b/message/validation/validation_test.go @@ -2152,7 +2152,7 @@ func Test_ValidateSSVMessage(t *testing.T) { timeIntoSlot := time.Duration(0) for { - currentRound, err := validator.estimatedRoundAt(role, timeIntoSlot) + currentRound, err := validator.estimatedRoundAt(role, slot, timeIntoSlot) require.NoError(t, err) if currentRound == round { break diff --git a/operator/validator/controller.go b/operator/validator/controller.go index fd2b6a30c1..3c5b0c974c 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -86,6 +86,7 @@ type ControllerOptions struct { ValidatorSyncer *metadata.Syncer Graffiti []byte ProposerDelay time.Duration + ProposerDelayEPBS time.Duration // worker flags WorkersCount int `yaml:"MsgWorkersCount" env:"MSG_WORKERS_COUNT" env-description:"Number of message processing workers"` @@ -210,6 +211,7 @@ func NewController(logger *zap.Logger, options ControllerOptions) *Controller { options.MessageValidator, options.Graffiti, options.ProposerDelay, + options.ProposerDelayEPBS, ) cacheTTL := 2 * options.NetworkConfig.EpochDuration() // #nosec G115 @@ -1239,6 +1241,7 @@ func SetupRunners( HighestDecidedSlot: 0, Graffiti: options.Graffiti, ProposerDelay: options.ProposerDelay, + ProposerDelayEPBS: options.ProposerDelayEPBS, ProposedBlockRoots: proposedBlockRoots, StartEnvelopeDuty: startEnvelopeDuty, }) diff --git a/protocol/v2/qbft/roundtimer/timer.go b/protocol/v2/qbft/roundtimer/timer.go index c01cd77cf5..2804b7392e 100644 --- a/protocol/v2/qbft/roundtimer/timer.go +++ b/protocol/v2/qbft/roundtimer/timer.go @@ -19,8 +19,12 @@ type OnRoundTimeoutF func(round specqbft.Round) const ( QuickTimeoutThreshold = specqbft.Round(8) - QuickTimeout = 2 * time.Second - SlowTimeout = 2 * time.Minute + // QuickTimeout is the per-round budget — a fixed network-round-trip allowance, not a slot fraction. + // It is intentionally NOT retimed for Gloas: under the tighter ~quarter-slot proposer deadline two + // 2s rounds no longer fit, so the Gloas proposer is effectively round-1-must-succeed (a deliberate + // choice, pending real-network round-trip data). Pre-Gloas behavior is unchanged. + QuickTimeout = 2 * time.Second + SlowTimeout = 2 * time.Minute ) var CutOffRound specqbft.Round = specqbft.Round(specqbft.CutoffRound) @@ -36,8 +40,8 @@ var CutOffRound specqbft.Round = specqbft.Round(specqbft.CutoffRound) // Round T+2 ends at headStart + T * quick + 2 * slow // // Every role has its own dedicated headStart duration. -func roundTimeoutForRound(role spectypes.RunnerRole, slotDuration time.Duration, round specqbft.Round) time.Duration { - headStart := round1HeadStart(role, slotDuration) +func roundTimeoutForRound(role spectypes.RunnerRole, intervalDuration time.Duration, round specqbft.Round) time.Duration { + headStart := round1HeadStart(role, intervalDuration) if round <= QuickTimeoutThreshold { return headStart + casts.DurationFromUint64(uint64(round))*QuickTimeout } @@ -47,20 +51,22 @@ func roundTimeoutForRound(role spectypes.RunnerRole, slotDuration time.Duration, } // round1HeadStart returns the extra time, on top of Round 1's normal quick timeout, that -// Round 1 is allowed to run for a given role. Committee gets 1/3 of the slot as head start +// Round 1 is allowed to run for a given role. Committee gets one interval as head start // (time for the block to become available); aggregator, aggregator-committee and -// sync-committee-contribution get 2/3 of the slot (time for attestations to arrive before -// aggregating); proposer gets zero. +// sync-committee-contribution get two intervals (time for attestations to arrive before +// aggregating); proposer gets zero. The interval is IntervalDuration — 1/3 of the slot +// pre-Gloas, 1/4 from Gloas — so the head starts track the retimed attestation/aggregate +// deadlines across the fork. // // Note: this is NOT the time at which Round 1 -> Round 2 transitions — that transition actually // happens at `slotStart + round1HeadStart + QuickTimeout`, because Round 1 still needs to run its // own quick timer on top of the head start. -func round1HeadStart(role spectypes.RunnerRole, slotDuration time.Duration) time.Duration { +func round1HeadStart(role spectypes.RunnerRole, intervalDuration time.Duration) time.Duration { switch role { case spectypes.RoleCommittee: - return slotDuration / 3 + return intervalDuration case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution, spectypes.RoleAggregatorCommittee: - return slotDuration / 3 * 2 + return 2 * intervalDuration default: return 0 } @@ -74,11 +80,11 @@ func round1HeadStart(role spectypes.RunnerRole, slotDuration time.Duration) time // IMPORTANT: the calculations in this func must be aligned with those in RoundTimeout, those funcs should re-use // the same code/algo - they currently don't since that would make one of them quite slow, instead the alignment // is enforced by unit-tests. -func EstimatedRoundAt(role spectypes.RunnerRole, slotDuration, timeIntoSlot time.Duration) (specqbft.Round, error) { +func EstimatedRoundAt(role spectypes.RunnerRole, intervalDuration, timeIntoSlot time.Duration) (specqbft.Round, error) { // Compute the round directly by inverting the piecewise-linear roundTimeoutOffset formula: // Quick phase (r <= T): offset(r) = headStart + r * quick // Slow phase (r > T): offset(r) = headStart + T * quick + (r - T) * slow - elapsed := timeIntoSlot - round1HeadStart(role, slotDuration) + elapsed := timeIntoSlot - round1HeadStart(role, intervalDuration) if elapsed < 0 { return specqbft.FirstRound, nil } @@ -129,27 +135,17 @@ func New(ctx context.Context, beaconConfig *networkconfig.Beacon, role spectypes } } -// RoundTimeout calculates the timeout duration for a specific role, height, and round. +// RoundTimeout returns the duration to wait before timing out the given round. // -// Timeout Rules: -// - For RoleCommittee, the base timeout (Round 1 head start) is 1/3 of the slot duration. -// - For RoleAggregator, RoleSyncCommitteeContribution and RoleAggregatorCommittee, it is 2/3 of the slot duration. -// - For RoleProposer, the timeout is either quickTimeout or slowTimeout, depending on the round. +// For RoleProposer, the timeout is round-relative (not slot-synchronized): +// - rounds <= QuickTimeoutThreshold → QuickTimeout +// - rounds > QuickTimeoutThreshold → SlowTimeout // -// Additional Timeout: -// - For rounds less than or equal to quickThreshold, the additional timeout is 'quick' seconds. -// - For rounds greater than quickThreshold, the additional timeout is 'slow' seconds. -// -// SIP Reference: -// For more details, see SIP at https://github.com/bloxapp/SIPs/pull/22 -// -// TODO: Update SIP for Deterministic Round Timeout -// TODO: Decide if to make the proposer timeout deterministic -// -// Synchronization Note: -// To ensure synchronized timeouts across instances, the timeout is based on the duty start time, -// which is calculated from the slot height. The base timeout is set based on the role, -// and the additional timeout is added based on the round number. +// For all other roles, the timeout is slot-synchronized via roundTimeoutForRound: +// it returns time.Until(slotStart + roundTimeoutForRound(role, IntervalDuration(slot), round)), +// so the result can be negative for duties that started late. The base timeout is one interval +// (attester/sync-committee) or two intervals (aggregator/sync-contribution/aggregator-committee); +// IntervalDuration is 1/3 of the slot before Gloas, 1/4 from Gloas on (SIP #94 §1). func (t *RoundTimer) RoundTimeout(round specqbft.Round) time.Duration { // Proposer runner round timeouts are currently relative to QBFT instance start time, not slot start time: // https://github.com/ssvlabs/ssv/issues/2429 @@ -162,7 +158,7 @@ func (t *RoundTimer) RoundTimeout(round specqbft.Round) time.Duration { // Slot-synchronized roles: timeout happens at slot start + roundTimeoutForRound(...). dutyStartTime := t.beaconConfig.SlotStartTime(t.slot) - return time.Until(dutyStartTime.Add(roundTimeoutForRound(t.role, t.beaconConfig.SlotDuration, round))) + return time.Until(dutyStartTime.Add(roundTimeoutForRound(t.role, t.beaconConfig.IntervalDuration(t.slot), round))) } // TimeoutForRound implements specqbft.Timer. diff --git a/protocol/v2/qbft/roundtimer/timer_test.go b/protocol/v2/qbft/roundtimer/timer_test.go index 7b10184989..9dcd5e4301 100644 --- a/protocol/v2/qbft/roundtimer/timer_test.go +++ b/protocol/v2/qbft/roundtimer/timer_test.go @@ -142,7 +142,7 @@ func TestEstimatedRoundAt(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - got, err := EstimatedRoundAt(tc.role, testBeaconConfig.SlotDuration, tc.timeIntoSlot) + got, err := EstimatedRoundAt(tc.role, testBeaconConfig.IntervalDuration(0), tc.timeIntoSlot) require.NoError(t, err) require.Equal(t, tc.want, got) }) @@ -208,12 +208,36 @@ func TestRoundTimeoutOffset(t *testing.T) { } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - got := roundTimeoutForRound(tc.role, slotDuration, tc.round) + got := roundTimeoutForRound(tc.role, slotDuration/3, tc.round) require.Equal(t, tc.want, got) }) } } +// TestRoundTimeoutOffsetGloasInterval verifies the head starts track IntervalDuration: passing the +// Gloas interval (1/4 of the slot, vs 1/3 pre-Gloas) shrinks the committee/aggregator head starts +// accordingly, so a stalled round 1 falls back to round 2 in step with the retimed deadlines. +func TestRoundTimeoutOffsetGloasInterval(t *testing.T) { + slotDuration := networkconfig.TestNetwork.SlotDuration + gloasInterval := slotDuration / 4 + + tt := []struct { + name string + role spectypes.RunnerRole + want time.Duration + }{ + {name: "committee head start = 1 interval", role: spectypes.RoleCommittee, want: slotDuration/4 + QuickTimeout}, + {name: "aggregator head start = 2 intervals", role: ssvtypes.RoleAggregator, want: slotDuration/2 + QuickTimeout}, + {name: "sync_committee_contribution head start = 2 intervals", role: ssvtypes.RoleSyncCommitteeContribution, want: slotDuration/2 + QuickTimeout}, + {name: "proposer head start = 0", role: spectypes.RoleProposer, want: QuickTimeout}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, roundTimeoutForRound(tc.role, gloasInterval, specqbft.FirstRound)) + }) + } +} + // TestEstimatedRoundAtBoundaries exercises each round's transition boundary for every role. // For every round r, it checks EstimatedRoundAt at: // - offset - 1ns → still in round r @@ -244,20 +268,20 @@ func TestEstimatedRoundAtBoundaries(t *testing.T) { // "late message" territory but EstimatedRoundAt is still defined and should // keep incrementing with the same rules. for round := specqbft.Round(1); round <= CutOffRound+2; round++ { - offset := roundTimeoutForRound(rc.role, slotDuration, round) + offset := roundTimeoutForRound(rc.role, slotDuration/3, round) // 1 ns before the boundary: round r has not yet timed out. - got, err := EstimatedRoundAt(rc.role, slotDuration, offset-time.Nanosecond) + got, err := EstimatedRoundAt(rc.role, slotDuration/3, offset-time.Nanosecond) require.NoError(t, err) require.Equal(t, round, got, "round %d: 1ns before boundary", round) // Exactly at the boundary: round r has timed out, we are now in round r+1. - got, err = EstimatedRoundAt(rc.role, slotDuration, offset) + got, err = EstimatedRoundAt(rc.role, slotDuration/3, offset) require.NoError(t, err) require.Equal(t, round+1, got, "round %d: exactly at boundary", round) // 1 ns after the boundary: still in round r+1 (until next boundary). - got, err = EstimatedRoundAt(rc.role, slotDuration, offset+time.Nanosecond) + got, err = EstimatedRoundAt(rc.role, slotDuration/3, offset+time.Nanosecond) require.NoError(t, err) require.Equal(t, round+1, got, "round %d: 1ns after boundary", round) } @@ -299,7 +323,7 @@ func TestEstimatedRoundAtEdgeCases(t *testing.T) { } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - got, err := EstimatedRoundAt(tc.role, slotDuration, tc.timeIntoSlot) + got, err := EstimatedRoundAt(tc.role, slotDuration/3, tc.timeIntoSlot) require.NoError(t, err) require.Equal(t, specqbft.FirstRound, got) }) @@ -335,7 +359,7 @@ func TestRoundTimeoutMatchesRoundTimeoutOffset(t *testing.T) { timer := New(t.Context(), beaconConfig, rc.role, 0, func(round specqbft.Round) {}) for round := specqbft.Round(1); round <= CutOffRound; round++ { - expected := roundTimeoutForRound(rc.role, beaconConfig.SlotDuration, round) + expected := roundTimeoutForRound(rc.role, beaconConfig.IntervalDuration(0), round) got := timer.RoundTimeout(round) require.Equal(t, expected, got, "round %d", round) } @@ -382,12 +406,12 @@ func TestEstimatedRoundAtMatchesRoundTimeout(t *testing.T) { } // 1 ns before the boundary: still in current round. - got, err := EstimatedRoundAt(rc.role, beaconConfig.SlotDuration, cumulative-time.Nanosecond) + got, err := EstimatedRoundAt(rc.role, beaconConfig.IntervalDuration(0), cumulative-time.Nanosecond) require.NoError(t, err) require.Equal(t, round, got, "round %d: 1ns before boundary", round) // Exactly at the boundary: advanced to next round. - got, err = EstimatedRoundAt(rc.role, beaconConfig.SlotDuration, cumulative) + got, err = EstimatedRoundAt(rc.role, beaconConfig.IntervalDuration(0), cumulative) require.NoError(t, err) require.Equal(t, round+1, got, "round %d: at boundary", round) } diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 75e01e7229..912b8f52ce 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -49,8 +49,9 @@ type ProposerRunner struct { // proposerDelay allows Operator to configure a delay to wait out before requesting Ethereum // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. - proposerDelay time.Duration + // higher MEV. proposerDelayEPBS is its post-Gloas counterpart (see proposerDelayForSlot). + proposerDelay time.Duration + proposerDelayEPBS time.Duration // cachedFullBlock holds the initially fetched full (non-blinded) block // for this duty on this operator, if any. Used so that the leader of the @@ -83,8 +84,9 @@ type ProposerRunnerOptions struct { Graffiti []byte // ProposerDelay allows Operator to configure a delay to wait out before requesting Ethereum // block to propose if this Operator is proposer-duty Leader. This allows Operator to extract - // higher MEV. - ProposerDelay time.Duration + // higher MEV. ProposerDelayEPBS is its post-Gloas counterpart, applied from the Gloas fork on. + ProposerDelay time.Duration + ProposerDelayEPBS time.Duration // ProposedBlockRoots is the shared store the proposer records its §4-decided block root into for the // §6 envelope runner to read. Optional (nil pre-Gloas / when the envelope runner is absent). @@ -119,6 +121,7 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { graffiti: opts.Graffiti, proposerDelay: opts.ProposerDelay, + proposerDelayEPBS: opts.ProposerDelayEPBS, proposedBlockRoots: opts.ProposedBlockRoots, startEnvelopeDuty: opts.StartEnvelopeDuty, }, nil @@ -186,7 +189,7 @@ func (r *ProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Lo } } - waitedOutProposerDelayEvent := fmt.Sprintf("waited out proposer delay of %dms", r.proposerDelay.Milliseconds()) + waitedOutProposerDelayEvent := fmt.Sprintf("waited out proposer delay of %dms", r.proposerDelayForSlot(duty.Slot).Milliseconds()) logger.Debug(waitedOutProposerDelayEvent) span.AddEvent(waitedOutProposerDelayEvent) @@ -282,7 +285,7 @@ func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Log logFields := []zap.Field{ fields.Slot(duty.Slot), - zap.Duration("proposer_delay", r.proposerDelay), + zap.Duration("proposer_delay", r.proposerDelayForSlot(duty.Slot)), fields.Took(time.Since(start)), } if bid := block.Body.SignedExecutionPayloadBid; bid != nil && bid.Message != nil { @@ -664,9 +667,19 @@ func (r *ProposerRunner) executeDuty(ctx context.Context, logger *zap.Logger, du return nil } +// proposerDelayForSlot returns the fork-appropriate proposer delay: proposerDelayEPBS from the Gloas +// fork on, proposerDelay before it. They are separate knobs because ePBS retimes the proposal deadline +// (slot quarters), so their safe ranges differ. +func (r *ProposerRunner) proposerDelayForSlot(slot phase0.Slot) time.Duration { + if r.NetworkConfig.IsGloasAtSlot(slot) { + return r.proposerDelayEPBS + } + return r.proposerDelay +} + func (r *ProposerRunner) remainingProposerDelay(slot phase0.Slot, now time.Time) time.Duration { slotTime := r.NetworkConfig.SlotStartTime(slot) - proposeTime := slotTime.Add(r.proposerDelay) + proposeTime := slotTime.Add(r.proposerDelayForSlot(slot)) if wait := proposeTime.Sub(now); wait > 0 { return wait } diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 800ed8cea3..54bd3f1fbc 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -251,6 +251,23 @@ func TestRemainingProposerDelay(t *testing.T) { } } +// proposerDelayForSlot is fork-gated: pre-Gloas uses ProposerDelay, Gloas-on uses ProposerDelayEPBS. +func TestProposerDelayForSlot(t *testing.T) { + const gloasEpoch = 5 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + r := &ProposerRunner{ + BaseRunner: &BaseRunner{NetworkConfig: netCfg}, + proposerDelay: 300 * time.Millisecond, + proposerDelayEPBS: 100 * time.Millisecond, + } + + preGloasSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) + gloasSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) + + require.Equal(t, 300*time.Millisecond, r.proposerDelayForSlot(preGloasSlot)) + require.Equal(t, 100*time.Millisecond, r.proposerDelayForSlot(gloasSlot)) +} + func TestProposerRunnerStartNewDutySkipsRandaoSigningWhenDoppelgangerBlocks(t *testing.T) { t.Parallel() diff --git a/protocol/v2/ssv/validator/opts.go b/protocol/v2/ssv/validator/opts.go index a38d49cc1c..c630a06755 100644 --- a/protocol/v2/ssv/validator/opts.go +++ b/protocol/v2/ssv/validator/opts.go @@ -51,6 +51,7 @@ type CommonOptions struct { MessageValidator validation.MessageValidator Graffiti []byte ProposerDelay time.Duration + ProposerDelayEPBS time.Duration } func NewCommonOptions( @@ -69,6 +70,7 @@ func NewCommonOptions( messageValidator validation.MessageValidator, graffiti []byte, proposerDelay time.Duration, + proposerDelayEPBS time.Duration, ) *CommonOptions { result := &CommonOptions{ NetworkConfig: networkConfig, @@ -86,6 +88,7 @@ func NewCommonOptions( MessageValidator: messageValidator, Graffiti: graffiti, ProposerDelay: proposerDelay, + ProposerDelayEPBS: proposerDelayEPBS, } // If full node, increase the queue size to make enough room for history sync batches to be pushed whole. From 29b013e5e7fc72cfe8d6947520941584220c09ab Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 09:58:09 +0300 Subject: [PATCH 064/150] gloas: scale attestation-data fetch budgets to the Gloas slot window ePBS compresses the attestation window from 1/3 to 1/4 of the slot (~4s->3s), but the attestation-data fetch budgets are keyed to a fork-agnostic HTTP timeout and don't follow it. Add scaleToAttestationWindow(base, slot) and apply it to the weighted-fetch hard/soft timeouts (and their soft/2 scoring + soft/4 block-header derivatives) and the refetch budget (minTimeForRetry/refetchDelay/refetchTimeout): the budget stays proportional to the window -- unchanged pre-Gloas, x3/4 from Gloas (integer math, nil-guarded for pre-init). The 100ms poll granularity is fork-agnostic and left as-is. Assumes BN response timings are fork-independent; pre-Gloas behavior is unchanged. --- beacon/goclient/attest.go | 36 ++++++++++++++++++++++++---------- beacon/goclient/attest_test.go | 19 ++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/beacon/goclient/attest.go b/beacon/goclient/attest.go index 1a57253cf5..be6f7bc3eb 100644 --- a/beacon/goclient/attest.go +++ b/beacon/goclient/attest.go @@ -129,10 +129,11 @@ func (gc *GoClient) verifyAndRefetchIfStale( ) if deadline, ok := ctx.Deadline(); ok { - if time.Until(deadline) < minTimeForRetry { + minRetry := gc.scaleToAttestationWindow(minTimeForRetry, slot) + if time.Until(deadline) < minRetry { logger.Debug("not enough time remaining for retry", zap.Duration("remaining", time.Until(deadline)), - zap.Duration("min_required", minTimeForRetry), + zap.Duration("min_required", minRetry), ) attestationDataRefetchSkippedCounter.Add(ctx, 1) return attData, true @@ -142,10 +143,10 @@ func (gc *GoClient) verifyAndRefetchIfStale( select { case <-ctx.Done(): return attData, true - case <-time.After(refetchDelay): + case <-time.After(gc.scaleToAttestationWindow(refetchDelay, slot)): } - refetchCtx, cancel := context.WithTimeout(ctx, refetchTimeout) + refetchCtx, cancel := context.WithTimeout(ctx, gc.scaleToAttestationWindow(refetchTimeout, slot)) defer cancel() newAttData, err := gc.fetchAttestationDataFunc(refetchCtx, slot) @@ -171,16 +172,31 @@ func (gc *GoClient) verifyAndRefetchIfStale( return newAttData, true } +// scaleToAttestationWindow scales a fetch budget calibrated for the pre-Gloas attestation window +// (one interval = 1/3 of the slot) to the slot's actual window: unchanged pre-Gloas, x3/4 from Gloas +// (the window shrinks to 1/4 of the slot, ~4s->3s on mainnet). BN response timings — what the budget +// waits on — are assumed fork-independent, so the budget tracks the window. Integer math (no float +// rounding): base * 3 / intervalsPerSlot. +func (gc *GoClient) scaleToAttestationWindow(base time.Duration, slot phase0.Slot) time.Duration { + cfg := gc.getBeaconConfig() + if cfg == nil { + return base // config not loaded yet (pre-init) — no scaling, i.e. pre-Gloas behavior + } + const preGloasIntervalsPerSlot = 3 + intervalsPerSlot := int64(cfg.SlotDuration / cfg.IntervalDuration(slot)) // 3 pre-Gloas, 4 from Gloas + return base * preGloasIntervalsPerSlot / time.Duration(intervalsPerSlot) +} + func (gc *GoClient) weightedAttestationData(ctx context.Context, slot phase0.Slot) (*phase0.AttestationData, error) { logger := gc.log.With(fields.Slot(slot), weightedAttestationDataRequestIDField(uuid.New())) // We have two timeouts: a soft timeout and a hard timeout. // At the soft timeout, we return if we have any responses so far. // At the hard timeout, we return unconditionally. // The soft timeout is half the duration of the hard timeout. - ctx, cancel := context.WithTimeout(ctx, gc.weightedAttestationDataHardTimeout) + ctx, cancel := context.WithTimeout(ctx, gc.scaleToAttestationWindow(gc.weightedAttestationDataHardTimeout, slot)) defer cancel() - softCtx, softCancel := context.WithTimeout(ctx, gc.weightedAttestationDataSoftTimeout) + softCtx, softCancel := context.WithTimeout(ctx, gc.scaleToAttestationWindow(gc.weightedAttestationDataSoftTimeout, slot)) defer softCancel() started := time.Now() @@ -411,7 +427,7 @@ func (gc *GoClient) scoreAttestationData(ctx context.Context, With(zap.Float64("base_score", score)). Debug("base score was set. Fetching slot for block root") - ctx, cancel := context.WithTimeout(ctx, gc.weightedAttestationDataSoftTimeout/2) + ctx, cancel := context.WithTimeout(ctx, gc.scaleToAttestationWindow(gc.weightedAttestationDataSoftTimeout, attestationData.Slot)/2) defer cancel() ticker := time.NewTicker(time.Millisecond * 100) @@ -423,7 +439,7 @@ func (gc *GoClient) scoreAttestationData(ctx context.Context, ) for { - slot, err := gc.blockRootToSlot(ctx, client, attestationData.BeaconBlockRoot, logger) + slot, err := gc.blockRootToSlot(ctx, client, attestationData.BeaconBlockRoot, attestationData.Slot, logger) if err == nil { // Increase score based on the nearness of the head slot. denominator := float64(1 + attestationData.Slot - slot) @@ -466,7 +482,7 @@ func (gc *GoClient) scoreAttestationData(ctx context.Context, } } -func (gc *GoClient) blockRootToSlot(ctx context.Context, client Client, root phase0.Root, logger *zap.Logger) (phase0.Slot, error) { +func (gc *GoClient) blockRootToSlot(ctx context.Context, client Client, root phase0.Root, attSlot phase0.Slot, logger *zap.Logger) (phase0.Slot, error) { cacheResult := gc.blockRootToSlotCache.Get(root) if cacheResult != nil { cachedSlot := cacheResult.Value() @@ -479,7 +495,7 @@ func (gc *GoClient) blockRootToSlot(ctx context.Context, client Client, root pha logger.Debug("slot was not found in cache, fetching from the client") - timeoutContext, cancel := context.WithTimeout(ctx, gc.weightedAttestationDataSoftTimeout/4) + timeoutContext, cancel := context.WithTimeout(ctx, gc.scaleToAttestationWindow(gc.weightedAttestationDataSoftTimeout, attSlot)/4) defer cancel() blockResponse, err := client.BeaconBlockHeader(timeoutContext, &api.BeaconBlockHeaderOpts{ diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index 1b2bde1d10..f4a47bb803 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/utils/hashmap" ) @@ -887,3 +888,21 @@ func TestVerifyAndRefetchIfStale_ContextCancelledDuringDelay(t *testing.T) { require.True(t, stale, "data is stale when context canceled") require.False(t, fetchCalled, "should not have called fetch when canceled during delay") } + +// scaleToAttestationWindow keeps fetch budgets proportional to the attestation window: unchanged +// pre-Gloas (1/3 of the slot), x3/4 from Gloas (1/4 of the slot). +func TestScaleToAttestationWindow(t *testing.T) { + const gloasEpoch = 5 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + gc := &GoClient{beaconConfig: netCfg.Beacon} + + preGloasSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) + gloasSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) + + // Pre-Gloas (1/3 window): unchanged. + require.Equal(t, 2*time.Second, gc.scaleToAttestationWindow(2*time.Second, preGloasSlot)) + require.Equal(t, 5*time.Second, gc.scaleToAttestationWindow(5*time.Second, preGloasSlot)) + // Gloas (1/4 window): x3/4. + require.Equal(t, 1500*time.Millisecond, gc.scaleToAttestationWindow(2*time.Second, gloasSlot)) + require.Equal(t, 3750*time.Millisecond, gc.scaleToAttestationWindow(5*time.Second, gloasSlot)) +} From 1d8aeb2b2e9443d6a6f6473fe4159e2f018fa831 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 11:24:03 +0300 Subject: [PATCH 065/150] gloas: fix stale ePBS submit comments, add submit-error test, tidy EKM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review touch-ups on the §4 block / §6 envelope submit paths; no behavior change (comments, a behavior-preserving refactor, and a new test). - envelope.go: submitEnvelope's comment claimed it mirrors the §4 block path, but that path became all-submit when the content-match was removed earlier in this PR. Explain why the envelope stays builder-only: its decided value is blinded, so only the builder holds the full payload bytes. - proposer.go: drop gloasProposalInput's "cached for self-build detection" line (that cache + detection were removed); the marshaled block is the DataSSZ consensus value directly. Reframe submitGloasProposal's BN-idempotency note as the pre-Gloas assumption, still to be confirmed against a real Gloas BN. - proposer_test.go: add a regression test that a BN submit error still starts the self-build envelope duty. - ekm: collapse the three byte-identical Gloas signSSZRoot arms into one case and table the three matching domain subtests (per-domain notes kept). --- protocol/v2/ssv/runner/envelope.go | 7 ++- protocol/v2/ssv/runner/proposer.go | 11 +++-- protocol/v2/ssv/runner/proposer_test.go | 21 +++++++++ ssvsigner/ekm/local_key_manager.go | 15 ++---- ssvsigner/ekm/local_key_manager_test.go | 61 +++++++++---------------- 5 files changed, 58 insertions(+), 57 deletions(-) diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 9c331aefda..73b71ae6f4 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -201,8 +201,11 @@ func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger } // submitEnvelope publishes the signed execution-payload envelope. Only the operator whose cached envelope -// blinds to the decided value (content match) holds the full bytes to publish; the others just complete -// the duty — mirroring the §4 block path. +// blinds to the decided value (content match) holds the full bytes to publish; the others just complete the +// duty. Unlike the §4 block path — where the bid-only block is itself the decided value, so every operator +// holds and re-submits it — the envelope's decided value is blinded; only its builder holds the full payload +// bytes, so content-match publication keeps a non-builder (whose cachedEnvelope is nil) from broadcasting an +// empty envelope. func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { if r.builtDecidedEnvelope(cd.DataSSZ) { signed := &gloas.SignedExecutionPayloadEnvelope{Message: r.cachedEnvelope, Signature: sig} diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 912b8f52ce..584b2523d3 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -269,8 +269,8 @@ func (r *ProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Lo // gloasProposalInput fetches the Gloas (ePBS) block this operator would propose and wraps it as the // QBFT consensus value. The block carries only the execution-payload bid (the payload ships in the §6 -// envelope), so unlike the pre-Gloas path there is no blinding; the marshaled block is cached so -// post-consensus can detect whether this operator built the decided block. +// envelope), so unlike the pre-Gloas path there is no blinding — the marshaled block is the QBFT +// consensus value (DataSSZ) directly. func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Logger, duty *spectypes.ValidatorDuty, randaoReveal []byte) (*spectypes.ProposerConsensusData, error) { start := time.Now() block, err := r.GetBeaconNode().GetGloasBeaconBlock(ctx, duty.Slot, r.graffiti, randaoReveal) @@ -521,9 +521,10 @@ func (r *ProposerRunner) finishSubmittedProposal(ctx context.Context, logger *za // submitGloasProposal publishes the decided Gloas (ePBS) block, then on the self-build path starts the §6 // envelope-signing duty. Every operator submits the decided block — the ePBS block is bid-only so all hold -// it, and submission is idempotent at the BN by root, keeping the pre-Gloas all-submit redundancy. The block -// is decoded up front because every operator needs its bid for the self-build check; the envelope trigger -// fires on every operator and dispatches async, so it never delays the block. +// it, keeping the pre-Gloas all-submit redundancy. That relies on the BN deduping duplicate submissions by +// root (battle-tested pre-Gloas; still to be confirmed against a real Gloas BN). The block is decoded up +// front because every operator needs its bid for the self-build check; the envelope trigger fires on every +// operator and dispatches async, so it never delays the block. func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Logger, span trace.Span, cd *spectypes.ProposerConsensusData, sig phase0.BLSSignature) error { block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) if err != nil { diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 54bd3f1fbc..2afa939622 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -2,6 +2,7 @@ package runner import ( "context" + "errors" "maps" "testing" "time" @@ -680,6 +681,26 @@ func TestProposerRunnerSubmitGloasProposalSkipsEnvelopeOnExternalBuild(t *testin require.False(t, called, "external-build should not start the envelope duty") } +// A BN submit error fails the duty, but the self-build envelope duty must still start — the envelope is a +// cluster round the others join regardless of this operator's block submit. +func TestProposerRunnerSubmitGloasProposalErrorStillTriggersEnvelope(t *testing.T) { + t.Parallel() + + const slot = phase0.Slot(8) + consensusData := gloasProposerConsensusData(t, slot) // self-build + beacon := newProposerTestBeacon(nil) + beacon.submitErr = errors.New("bn rejected") + runner, keySet, _ := newProposerRunnerForTest(t, beacon, &stubDoppelganger{canSign: true}, 0, nil) + setupRunnerForPostConsensus(t, runner, keySet, gloasProposerDuty(slot), consensusData, 1) + + called := false + runner.startEnvelopeDuty = func(phase0.Slot) { called = true } + + err := runner.submitGloasProposal(context.Background(), zap.NewNop(), trace.SpanFromContext(context.Background()), consensusData, phase0.BLSSignature{}) + require.ErrorContains(t, err, "submit gloas beacon block") + require.True(t, called, "envelope must still start even when the block submit fails") +} + // recordDecidedBlockRoot stores exactly block.HashTreeRoot() — the root the §6 envelope value-check // matches against — and is a no-op without a store. func TestProposerRunnerRecordDecidedBlockRoot(t *testing.T) { diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index 5bd79ea3e6..a8e79bf7e4 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -244,17 +244,10 @@ func (km *LocalKeyManager) signBeaconObject( return nil, nil, fmt.Errorf("obj type is unknown: %T", obj) } return km.signer.SignRegistration(data, domain, pubKey[:]) - case spectypes.DomainPTCAttester: - // Gloas (ePBS) PTC payload attestation: a plain BLS signature over the SSZ root under - // DomainPTCAttester, with no slashing protection (it is not in the slashing predicate). - return signSSZRoot(km.signer, obj, domain, pubKey[:]) - case spectypes.DomainProposerPreferences: - // Gloas (ePBS) proposer preferences: a plain BLS signature over the SSZ root under - // DomainProposerPreferences, with no slashing protection. - return signSSZRoot(km.signer, obj, domain, pubKey[:]) - case spectypes.DomainBeaconBuilder: - // Gloas (ePBS) §6 execution-payload envelope: a plain BLS signature over the blinded envelope's - // SSZ root under DomainBeaconBuilder, with no slashing protection. + case spectypes.DomainPTCAttester, spectypes.DomainProposerPreferences, spectypes.DomainBeaconBuilder: + // Gloas (ePBS) domains — a plain BLS signature over the SSZ root, no slashing protection (none is + // in the slashing predicate): DomainPTCAttester (PTC payload attestation), DomainProposerPreferences + // (proposer preferences), and DomainBeaconBuilder (§6 blinded execution-payload envelope). return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 6da055e56d..64627416cb 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -313,45 +313,28 @@ func TestSignBeaconObject(t *testing.T) { }) // The Gloas (ePBS) domains sign a generic SSZ root via signSSZRoot (no slashing protection); the obj // type is incidental — the point is each domain is handled, not falling through to "domain unknown". - t.Run("DomainBeaconBuilder", func(t *testing.T) { - _, sig, err := km.(*LocalKeyManager).SignBeaconObject( - ctx, - spectypes.SSZUint64(1), - phase0.Domain{}, - phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), - currentSlot, - spectypes.DomainBeaconBuilder, - ) - require.NoError(t, err) - require.NotNil(t, sig) - require.NotEqual(t, [32]byte{}, sig) - }) - t.Run("DomainPTCAttester", func(t *testing.T) { - _, sig, err := km.(*LocalKeyManager).SignBeaconObject( - ctx, - spectypes.SSZUint64(1), - phase0.Domain{}, - phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), - currentSlot, - spectypes.DomainPTCAttester, - ) - require.NoError(t, err) - require.NotNil(t, sig) - require.NotEqual(t, [32]byte{}, sig) - }) - t.Run("DomainProposerPreferences", func(t *testing.T) { - _, sig, err := km.(*LocalKeyManager).SignBeaconObject( - ctx, - spectypes.SSZUint64(1), - phase0.Domain{}, - phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), - currentSlot, - spectypes.DomainProposerPreferences, - ) - require.NoError(t, err) - require.NotNil(t, sig) - require.NotEqual(t, [32]byte{}, sig) - }) + for _, tc := range []struct { + name string + domain phase0.DomainType + }{ + {"DomainBeaconBuilder", spectypes.DomainBeaconBuilder}, + {"DomainPTCAttester", spectypes.DomainPTCAttester}, + {"DomainProposerPreferences", spectypes.DomainProposerPreferences}, + } { + t.Run(tc.name, func(t *testing.T) { + _, sig, err := km.(*LocalKeyManager).SignBeaconObject( + ctx, + spectypes.SSZUint64(1), + phase0.Domain{}, + phase0.BLSPubKey(sk1.GetPublicKey().Serialize()), + currentSlot, + tc.domain, + ) + require.NoError(t, err) + require.NotNil(t, sig) + require.NotEqual(t, [32]byte{}, sig) + }) + } } func TestRemoveShare(t *testing.T) { From 817ce5e07a10bfa31cbae5c5ec6ed5c45b5deb0c Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 11:29:11 +0300 Subject: [PATCH 066/150] gloas: commit ePBS implementation plan as temporary in-branch scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote EPBS_IMPLEMENTATION_PLAN.md from local-only (.git/info/exclude) into the branch so the in-flight ePBS planning context is shared, not local. The file carries an explicit action item: before #2901 is marked ready for review, move all remaining/useful action items into the PR description and delete this file — it must not outlive the PR. --- EPBS_IMPLEMENTATION_PLAN.md | 414 ++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 EPBS_IMPLEMENTATION_PLAN.md diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..5d1c19936c --- /dev/null +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,414 @@ +# ePBS (EIP-7732 / Gloas) — ssv node implementation plan + +**Working/ephemeral document.** Scratch space for planning the node-side implementation of [SIP ssvlabs/SIPs#94](https://github.com/ssvlabs/SIPs/pull/94). Delete once the work lands. Do not reference from code, comments, or other docs. + +> **⚠️ ACTION — delete this file before [#2901](https://github.com/ssvlabs/ssv/pull/2901) is marked ready for review.** It is committed (rather than kept local) only as a temporary shared home for in-flight ePBS context. Before flipping #2901 to *ready for review*, move **all** still-useful / unfinished action items (e.g. the devnet e2e steps, the devnet-verify items, the upstream-gated follow-ups, the MEV_CONSIDERATIONS.md rewrite, the ProposerPreferences publish-finality follow-up) into the PR description, then remove this file in the same PR. Nothing here should outlive the PR. + +**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged on the live devnet-5; PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the `…Contents` blob-carrying publish body (deferred — devnet-gated) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. + +**Baseline — the Boole fork (`boole-fork` is canonical).** ePBS builds on the SSV **Boole** protocol fork (successor to Alan): ssv-spec bumped `v1.2.2 → v1.2.3-pseudo`; `RoleAggregatorCommittee=6` with deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps; `SSVForks{ Boole }` + transition-window machinery; proposer round-robin; `lowestHash` topic→subnets. **Boole already shipped slices of this plan:** the node-side switches ePBS planned now exist — `protocol/v2/types/runner_role.go` (`RunnerRoleForValidatorDuty(duty, isBooleFork)`, fork-aware) and `protocol/v2/types/consensus_data.go` (version-switched extraction over `spectypes.ProposerConsensusData`). So **T7/T10 extend those, they don't author wrappers.** **`boole-fork` has not yet landed on stage** (stage is still `ssv-spec v1.2.2` / `SSVForks struct{}`; `boole-fork` is ~46 commits behind stage, tip Apr 2026) — but it's **expected to merge within ~2-3 weeks (≈ mid-July 2026), treated as ground truth** (see §2b). The build baseline is **`boole-fork`** (verify anchors against it). **Decision: ePBS starts now off `boole-fork`** — it must begin immediately for independent development + testing (against T2 mocks/devnet, which doesn't gate on Boole landing), so it can't wait for the merge; it rebases onto stage when Boole lands (~2-3 weeks) — see §6. The pre-Boole HEAD pin (`82a9f4f8f`) and §0/U findings are pre-Boole; corrections are inline where they flip (U0/U5/T7/T10/T11). + +**Scope:** the `ssv` node (this repo). All new protocol types are added **node-side** — aligned with the team decision to **migrate off ssv-spec imports entirely** (end-state). ePBS adds its types node-side and must not deepen ssv-spec coupling, but does **not** execute the full migration (separate initiative — see U0). **Deviation (PTC, as built):** the ePBS wire **constants** (roles/domains/partial-sig types) were added to **ssv-spec via [PR #632](https://github.com/ssvlabs/ssv-spec/pull/632)** — the established `spectypes` pattern, and required because ekm/ssvsigner reaches signing domains only through `spectypes` (a separate module that can't import node-side gloas); the node-side `protocol/v2/types/gloas` package holds only the **wire types**. The SIP remains the canonical wire source. Upstream-unmerged beacon APIs are **abstracted behind interface methods and mocked now**, swapped when upstream stabilizes. + +**Source pins (re-verify at implementation start — the spec is still pre-final):** +- Consensus specs: `ethereum/consensus-specs@6ebb2216c` (Gloas) +- The SIP's own text: [ssvlabs/SIPs#94](https://github.com/ssvlabs/SIPs/pull/94) +- Beacon APIs: produceBlockV4 + envelope endpoints in [beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) (open, head `bed49d98`); PTC + payload-attestation endpoints already tagged in `beacon-APIs@v5.0.0-alpha.2` + +**Non-goals / out of scope:** +- Anchor (Rust client) — coordinated separately; wire-level constants must match (see U0). +- Retiring `BeaconVote` / renaming `GloasBeaconVote` → `BeaconVote` — follow-up SIP, post-fork. +- The AggregatorCommittee consolidation refactor — **shipped by the Boole fork** (`boole-fork`), not ePBS; ePBS builds on the already-consolidated baseline (`RoleAggregatorCommittee=6`). See U0. +- The **full migration off ssv-spec imports** — a separate, larger initiative (main cost: spectest decoupling). ePBS only lays node-side types consistent with it; it does not execute the migration (see U0). +- The on-chain/EL side of ePBS. + +--- + +## 0. Verified facts (settled inputs) + +Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Treat as inputs, not open questions. *(Spec claims below re-verified against `consensus-specs@6ebb2216c`, SIP #94, and beacon-APIs#580 on 2026-06-18 — notes inline; re-verify again at T1 start, the spec is pre-final.)* + +**Spec correctness (against `consensus-specs@6ebb2216c`):** +- §1 timing (attest/sync 25%, agg/contrib 50%, PTC 75%); §2 `index` semantics (same-slot⇒0, else 0=EMPTY/1=FULL); §3 `PayloadAttestationData{beacon_block_root, slot, payload_present, blob_data_available}`; domains `0x0B`/`0x0C`/`0x0D`; §5 `ProposerPreferences{dependent_root, proposal_slot, validator_index, fee_recipient, target_gas_limit}` and `get_upcoming_proposal_slots` (current epoch → `MIN_SEED_LOOKAHEAD` ahead). +- **§6 HTR equivalence holds**: real `ExecutionPayloadEnvelope` = `{payload, execution_requests, builder_index, beacon_block_root, parent_beacon_block_root}`; the blinded form mirrors it with only `payload`→`payload_root`. +- **§6 envelope is signed by the proposer's validator key** (`verify_execution_payload_envelope_signature` in `gloas/fork-choice.md`: self-build is flagged by the sentinel `builder_index == BUILDER_INDEX_SELF_BUILD` = `UINT64_MAX`, and the verifying key is then `state.validators[state.latest_block_header.proposer_index].pubkey` under `DOMAIN_BEACON_BUILDER`) — an ordinary BLS verify against the validator key, so SSV distributed BLS-share signing is correct. (Note: `builder_index` is *not* the validator index in self-build; the non-self-build branch reads a separate `state.builders[...]` registry.) + +**Node-side facts (resolved during research):** +- **No SSV `DomainType` bump needed for Gloas** (conclusion holds; pre-Boole basis corrected). Gloas is a **beacon** fork — gate it by beacon epoch via `Beacon.BeaconForkAtEpoch` (Boole's rename of `ForkAtEpoch`; see U5). The pre-Boole basis ("`SSVForks` is an empty struct") is now **false**: Boole made `SSVForks{ Boole phase0.Epoch }` and added a `DomainType`/`NextDomainType` rotation (`networkconfig/ssv.go`). But those are **SSV-fork** (Boole) concerns, distinct from beacon forks, and the Gloas signing domains (`0x0B/0C/0D`) are beacon-side — so the SSV `DomainType` is untouched. Open at T1: whether Gloas also wants an `SSVForks` entry now that the machinery exists, or stays purely beacon-epoch-gated (default: beacon-epoch — it's beacon-driven). +- **Slashing protection needs no change.** Double-vote/surround detection compares **only `Source.Epoch`/`Target.Epoch`** — eth2-key-manager's `NormalProtection.IsSlashableAttestation` (reached via `ssvsigner/ekm/slashing_protector.go`) never inspects `index`, and by design doesn't even store signing roots. The full `AttestationData` is SSZ-persisted (`ssvsigner/ekm/signer_storage.go`), but only the source/target epochs are ever read for the slashable check — so the Gloas `index` semantics change is irrelevant to slashing, not because `index` is now part of the compared data but because it never was. The only index-aware work is in the value-check construction (T4). +- **A second concurrent QBFT instance for the same (validator, slot) is cleanly supported.** The two roles stay separate: message-validation consensus state and validation locks are keyed by `MessageID` (which includes `RunnerRole`), and runner queues by `RunnerRole` — so EnvelopeProposer (§6 QBFT variant) won't collide with the proposer's block QBFT. **Caveat:** this isolation holds on the per-validator runner path (`protocol/v2/ssv/validator/validator.go`, role-keyed `Queues`); the Committee path keys queues by **slot** (`committee.go`), not role. The three new roles must therefore be wired as per-validator runners, never onto the committee path (see T10). +- **The shared Gloas vote is inert for sync-committee duties.** The committee runner's sync path reads only `BeaconVote.BlockRoot` (`committee.go` sync-message construction); adding `AttestationDataIndex` to the vote doesn't touch it. +- **`api.VersionedProposal.Blinded`** is the local-vs-builder signal (already logged via `BeaconBlockIsBlindedAttribute`) — the basis for the U6 local-build metric. + +**Dependency landscape (resolved):** +- **go-eth2-client has no Gloas types** — neither the SSV fork (`v0.6.31-…`, based on upstream v0.27.0) nor upstream master (`v0.28.1`). No `DataVersionGloas`, no `spec/gloas` package. → the fork must be patched (U2). +- **ssv-spec has no Gloas work** (no branch/PR). The **AggregatorCommittee consolidation is now the baseline** — Boole bumps the node to `ssv-spec v1.2.3-pseudo` (`RoleAggregatorCommittee=6`; deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps), so the pre-Boole "`v1.2.2` contiguous/pre-consolidation" framing no longer applies. → U0. + +--- + +## 1. How to use this plan + +- **The investigations (§2) are resolved** — their decisions are baked into the tracks. Implementation can start. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (see U1). The only gate left in **§2b** is upstream API maturity (mock + watch). +- Tracks (§3) are dependency-ordered; graph in §4. +- Symbol names/anchors were verified against **pre-Boole** HEAD `82a9f4f8f`; the Boole baseline (`boole-fork`) changes some of them — often small diffs, and some seams already exist on stage (e.g. the `GLOAS_FORK_EPOCH` TODO at `spec.go:255`) — so **re-verify against `boole-fork`** at implementation start. Line numbers are approximate (`~`) and may drift. +- Each new runner role touches five seams: **(a)** type/enum, **(b)** runner impl, **(c)** `SetupRunners` registration, **(d)** duty handler/trigger, **(e)** message validation. + +--- + +## 2. Resolved investigations (findings + decisions) + +### U0 — How the new protocol types enter the node **(decided — incl. the ssv-spec posture)** +**Findings:** +- The new wire constants (roles, partial-sig types, domains, `GloasBeaconVote`, `EnvelopeConsensusData`, `BlindedExecutionPayloadEnvelope`) **do not exist in canonical ssv-spec** — there's no Gloas branch/PR. SSV must author them regardless of bump-vs-copy. +- ssv-spec's AggregatorCommittee consolidation is now **the baseline** — it lands in stage via the **Boole** fork (`ssv-spec v1.2.3-pseudo`). ePBS builds **on top of** the consolidated roles; the old "decouple an unreleased refactor" framing is obsolete. +- Wire values are protocol-canonical and must match Anchor; the **SIP (not an ssv-spec PR) is the canonical wire source**. + +**Decision/recommendation:** +- **The consolidation is the Boole baseline, not an ePBS prerequisite** — ePBS builds on it (shipped via `boole-fork`); it never owns or bumps it. +- **Add the Gloas types in-tree now** (e.g. a `gloastypes` package), using the SIP's canonical values: `RunnerRole` PTC=7/Prefs=8/Envelope=9; `BeaconRole` 7/8/9; `PartialSigMsgType` `PTCAttesterPartialSig`=7, `ProposerPreferencesPartialSig`=8 (+ `EnvelopePartialSig`=9 only if U1 picks no-QBFT); domains `0x0B/0x0C/0x0D`. These slot cleanly above the now-baseline consolidated max (`RoleAggregatorCommittee=6`), verified against post-Boole `ssv-spec v1.2.3-pseudo`. (Wire values verified against SIP #94: roles 7/8/9, partial-sigs 7/8, domains `DomainBeaconBuilder=0x0B`/`DomainPTCAttester=0x0C`/`DomainProposerPreferences=0x0D`; the SIP reserves RunnerRole 1/3 for pre-consolidation back-compat decoding.) +- **Extend Boole's existing node-side switches — don't author new wrappers.** Boole already moved both switches node-side (for its own consolidation back-compat): `protocol/v2/types/consensus_data.go` version-switches extraction over `spectypes.ProposerConsensusData` (the `getBlockData` seam), and `protocol/v2/types/runner_role.go`'s `RunnerRoleForValidatorDuty(duty, isBooleFork)` is the fork-aware duty→role map (replacing `spectypes.MapDutyToRunnerRole` node-side). So T7 adds a `DataVersionGloas` arm to `consensus_data.go`, and T10 adds the three new roles + an `isGloas` branch to `RunnerRoleForValidatorDuty` (mirroring its existing `isBooleFork` shape). The "wrappers" are extension points already in-tree — and already the migration seams. **Parameterizes T7, T10.** +- **Lock the Anchor wire constants early, not at end-of-execution.** They're already in the SIP (roles 7/8/9, partial-sigs 7/8, domains `0x0B/0C/0D`) and isolated — so confirming them with Anchor is *cheap now* and *expensive to discover wrong at interop*. Isolation makes them cheap to lock early, not a reason to defer. Proceed with the SIP-canonical values and get Anchor's explicit ack up front (against the SIP — the canonical wire source, no ssv-spec PR) rather than deferring to the end (see §2b). + +**ssv-spec posture (decided): migrate off ssv-spec imports entirely** (end-state). ePBS runs in the pragmatic fallback: **define the new types we need node-side; never modify, bump, or PR ssv-spec.** Guiding principle for every track — *do not deepen ssv-spec coupling*: new types are node-side; existing ssv-spec types are extended (node-side constants of the existing type) or wrapped, never edited; any node-side type that mirrors an ssv-spec one stays **SSZ/wire-identical** through the hybrid phase. The consolidation shipped via Boole's ssv-spec bump (`v1.2.3`) and is the baseline; ePBS neither bumps nor owns it. **Parameterizes T1, T7, T10, and all role work.** + +**Relationship to the full migration:** owning *all* duty-related types node-side (`ValidatorConsensusData`/`BeaconVote`/duty/role/value-check, `MessageID`, the QBFT types, …) is a **separate, larger initiative** — out of scope here. ePBS is its **first down-payment**: the Gloas types land node-side, and the wrappers (T7/T10) are the seams the migration will later cut. That migration's main cost is **spectest decoupling** (the node runs ssv-spec test vectors) — but ePBS doesn't pay it: Gloas has no ssv-spec vectors, so the new node-side types don't disturb existing spectest compatibility. Forward-compat: the Gloas role/partial-sig constants are values of the *existing* `spectypes` base types for now, and move wholesale when those base types are reimplemented node-side. Note: nothing in ePBS *forces* an ssv-spec edit — QBFT is value-agnostic (a new instance over `BlindedExecutionPayloadEnvelope` needs only a node-side value-check), and `MessageID` is reused read-only with node-side role values. **Steady-state caveat (largely moot post-Boole):** the duty→role / consensus-data switches T7/T10 extend are **already in-tree as Boole's** (`runner_role.go`/`consensus_data.go`), serving Boole's own consolidation back-compat — so they're load-bearing regardless of whether the ssv-spec migration ever runs. Only `gloastypes` is genuinely ePBS-introduced, and it's a clean node package fine to live with permanently. + +### U1 — §6 envelope distribution: QBFT vs no-QBFT(sign-all) **(RESOLVED — QBFT; SIP #94 maintainer call, 2026-06-23)** +**Decision: QBFT** — keep the SIP's prescribed shape (a second QBFT round over the blinded envelope; **no** new `PartialSigMsgType` — post-consensus reuses `PostConsensusPartialSig`, role discriminates routing). GalRogozinski on the §6 thread ([r3460315536](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3460315536)): *"QBFT is the correct call for now. It handles the faulty leader edge case. The way forward is for later SIPs to reduce the QBFT timeout. Eventually we need a more suitable consensus algo."* This **overrules** the earlier node-side recommendation (no-QBFT sign-all). What carried it (shane-moore's surface argument, [r3364710775](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3364710775)): the no-QBFT path's real cost is a **new top-level network message class** — a dissemination carrier for the `BlindedExecutionPayloadEnvelope` plus a new `EnvelopePartialSig` kind — touching the message-validation dispatch, a new validated-message variant, receiver routing, and byte-compat across every client (go-ssv, Anchor, future). QBFT reuses existing consensus machinery with **no new message class** and the default round-robin leader; its degeneracy (a round-changed envelope leader ≠ block proposer) is mild given the ~6s budget from the 25% block deadline to the 75% payload-due cutoff. The latency/Byzantine edge for no-QBFT was judged not to outweigh that surface for a path expected to be rare (local-build ~0.1–2%). **Contained to T8 (ships last).** T8 builds the QBFT variant only; the no-QBFT design is retired (kept for history). + +### U2 — go-eth2-client Gloas support + beacon API maturity **(decided)** +**Findings:** +- Gloas is absent from both the SSV fork and upstream — **there is nothing to bump to**. +- produceBlockV4 confirmed: response is `anyOf [Gloas.BeaconBlock, Gloas.BlockContents]` (per #580 head — `anyOf`, not `oneOf`; no blinded-block variant post-Gloas), discriminated by the `Eth-Execution-Payload-Included` header (+ `Eth-Consensus-Version: gloas`); SSZ or JSON. Envelope POST (`publishExecutionPayloadEnvelope`) accepts `SignedExecutionPayloadEnvelopeContents` (stateless) **or** bare `SignedExecutionPayloadEnvelope` (stateful); envelope GET returns `Gloas.ExecutionPayloadEnvelope`. +- **Endpoint maturity tiers:** + - **Merged/tagged** (lower risk): PTC duties, `payload_attestation_data`, `payload_attestations` pool — in `beacon-APIs@v5.0.0-alpha.2` and master. + - **Unmerged** (pin to #580, expect churn): produceBlockV4, envelope POST/GET, `types/gloas/{block_contents,execution_payload_envelope}`. + - **Nonexistent** (fully abstract, no real BN to test against): validator-facing `SignedProposerPreferences` publication. + +**Decision (revised — full node-side implementation; supersedes the earlier mock-only stance):** build the Gloas beacon surface node-side **now** — full-fidelity SSZ/JSON types (BeaconBlock/BlockContents, ExecutionPayloadEnvelope + Contents, PayloadAttestation/Data/Message + PTCDuty, ProposerPreferences/Signed) **and** hand-rolled HTTP endpoint clients — not abstract-and-mock-only. Rationale: a multi-client Glamsterdam/Gloas devnet is **live** (since ~May 2026), so a *real* implementation can be e2e-tested against a local devnet (see T12 / ssv-mini); mock-only can't be. Mocks are kept, but only for unit tests. The go-eth2-client rebase is now a **later dedup** — swap our node-side types/clients for upstream's `spec/gloas` when it ships — **not** a prerequisite or a gate. Types/endpoints are `#580`-pinned and will churn → watch + iterate. (`SignedProposerPreferences` is the lone exception: no endpoint exists in any client, so it stays mock-only — see §2b.) **Parameterizes T2.** + +### U3 — `BeaconNode` interface diff + mocks **(decided)** +Add to `protocol/v2/blockchain/beacon/client.go` (mocks regen via `//go:generate mockgen` at `client.go:~15`): +- `PTCCalls`: `PTCDuties(epoch)`, `PayloadAttestationData(slot)`, `SubmitPayloadAttestations(...)` — merged endpoints. +- `ProposerPreferencesCalls`: `SubmitProposerPreferences(...)` — **no real endpoint yet; mock only**. Supersedes `ValidatorRegistrationCalls` post-fork. +- `ExecutionPayloadEnvelopeCalls`: `GetExecutionPayloadEnvelope(slot, beaconBlockRoot)`, `SubmitExecutionPayloadEnvelope(contents | bare)` — #580. +- produceBlockV4 path on `ProposerCalls.GetBeaconBlock` (version + `Eth-Execution-Payload-Included`-aware). **Parameterizes T2.** + +### U4 — Message-validation model + ProposerPreferences carried-slot **(decided; impl in T9)** +**Findings/decisions:** +- Per-role allowances (`partialSignatureTypeMatchesRole`, `validRole`): PTC + ProposerPreferences are **partial-sig only** (reject consensus messages, like ValidatorRegistration); EnvelopeProposer is **QBFT + post-consensus**. +- **Carried slot (corrected — supersedes the earlier emission-slot sketch):** the base runner ties **three** checks to a single `DutySlot` — `validatePartialSigMsg` (receiver requires `msg.Slot == DutySlot`), `verifyExpectedRoot` (signing-domain epoch from `DutySlot`), and network `messageEarliness`. The wire signature is under `epoch(proposal_slot)`, so `DutySlot = proposal_slot` is **forced**, hence `msg.Slot = proposal_slot` (an emission-slot override would make every receiver drop the message as "slot already passed"). The future `proposal_slot` is instead permitted by a **bounded role-specific earliness allowance** in `messageEarliness` (T9) — not by runner slot-trickery. +- **Pre-fork acceptance:** message validation must accept `RoleProposerPreferences` for `~MIN_SEED_LOOKAHEAD` epoch(s) before `GLOAS_FORK_EPOCH` (fork-aware allow). This is net-new logic — there is **no existing ValidatorRegistration fork-cutoff in message validation to mirror**; both this pre-fork accept and the VR post-fork reject (T5) are new and must be built symmetrically. The gate to design around is `messageEarliness` (role-agnostic, no per-role exemption), which would otherwise reject a future-slot message. +- `dutyLimit` (`common_checks.go:~117`, whose `default` returns `(0, false)` — an *exists* flag the caller uses to skip the limit, not a numeric no-limit) and `messageLateness` (no `default` → unlisted role treated late just past slot-start) both need explicit arms for the three roles (`dutyLimit` arms returning `(limit, true)`). Note `validRole`/`partialSignatureTypeMatchesRole` **already** have arms for the non-QBFT roles (ValidatorRegistration/VoluntaryExit) — a clean template to copy; only `maxRound` (default→error) and `dutyLimit` (default→`(0,false)`) truly lack arms. +**Parameterizes T5, T6, T9.** + +### U5 — Fork-gating + slashing **(decided)** +**Findings/decisions:** +- **Gating:** beacon fork epochs are fetched at runtime from the BN's `/eth/v1/config/spec` (not node config). Wire Gloas by: adding `DataVersionGloas` (via the U2 fork patch), filling the `GLOAS_FORK_EPOCH` TODO in `beacon/goclient/spec.go` (**already present on stage at `spec.go:255`**), adding Gloas to the beacon fork-epoch method, and a node `IsGloas(slot/epoch)` helper. No per-network config-file changes. **Boole-baseline note (corrected):** `spec.go`/`networkconfig/beacon.go` are **not** rewritten by Boole — small diffs only (the GLOAS TODO + fork list pre-exist on stage, so these seams are *more* stable than feared). The one relevant change is the beacon fork method rename `ForkAtEpoch` → `BeaconForkAtEpoch` (`beacon.go:141` on `boole-fork`), done to disambiguate from Boole's new SSV-fork `BooleForkAtEpoch`. Model `IsGloas` on the **beacon** method (`BeaconForkAtEpoch`), **not** the SSV-fork `BooleForkAtEpoch` — Gloas is beacon-driven (see §0). +- **`BeaconVote` ↔ `GloasBeaconVote`** selected by the duty slot's fork; SSZ length differs (112B vs 120B) so cross-fork decode fails cleanly. +- **Slashing: no change** (see §0). **Parameterizes T1, T4.** + +### U6 — Local-build / reconstruction telemetry **(decided)** +Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=false` ≈ local; a **pre-Gloas proxy** — the signal changes post-fork). PTC reconstruction-miss and ProposerPreferences reconstruction-failure: new counters in those runners. **Parameterizes T13** (and informs T8 priority). + +### Newly confirmed (folded into tracks) +- Sync-committee path inert to the new vote field → noted in T4. +- `GLOAS_FORK_EPOCH` schedule is external (Glamsterdam ~Q3 2026; devnets now) → T11 / §2b. + +--- + +## 2b. Remaining open items (not resolvable now) + +| Item | Why open | Handling | +|------|----------|----------| +| **Boole→stage landing (scheduled)** | `boole-fork` (canonical) hasn't merged yet — ~46 behind stage, tip Apr 2026 — but lands **within ~2-3 weeks (≈ mid-July 2026), treated as ground truth**. Still the serial gate for **Boole → ePBS → migration-M1**. | **ePBS starts now off `boole-fork`** (decided — needs independent testing in parallel, can't wait) and rebases onto stage at landing (§6); the 46-commit reconciliation lands with the merge. Residual risk only if the 2-3 weeks slips — monitor. *(Update: `boole-fork` refreshed via #2899/#2900; ePBS rebased onto it — small reconciliation surface, see §6.)* | +| **U1 — §6 QBFT vs no-QBFT** | ~~Genuine design call~~ **RESOLVED → QBFT** | SIP #94 maintainer (GalRogozinski) call, 2026-06-23: keep QBFT (handles the faulty-leader case; reuses existing machinery, no new message class). Contained to T8 (ships last). | +| **go-eth2-client Gloas support** | Absent upstream | Build full Gloas types + endpoint clients **node-side now** (T2); swap for upstream `spec/gloas` as a later **dedup** when it ships — not a gate | +| **produceBlockV4 + envelope endpoints** | beacon-APIs#580 unmerged, may churn | Implement node-side against #580; pin + watch for churn; e2e on the local Gloas devnet (T2/T7/T8) | +| **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | +| **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | +| **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | +| **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13), revisit §6 priority and any no-QBFT tuning post-deploy | +| **Anchor wire-constant lock** | Cross-client agreement; cheap now, expensive at interop | **Partially verified** (sigp/anchor `epbs` branch): PTC constants match exactly (`Role::PTCAttester=7`, `PartialSignatureKind::PTCAttester=7`, validator-scoped); domains `0x0B/0C/0D` + domain epochs (`epoch(proposal_slot)`/`epoch(data.slot)`) match consensus-specs = #632. Anchor hasn't built §5/§6 yet (PTC-first, like us) → SIP + consensus-specs are the shared reference (verified); re-check §5/§6 constants when Anchor adds them. | +| **Full migration off ssv-spec** (all duty types) | Direction **decided**; execution is a separate, larger initiative | Out of scope here; ePBS adds Gloas types node-side as the first down-payment (see U0). Main cost (spectest decoupling) is the migration's, not ePBS's | + +--- + +## 3. Implementation tracks (dependency-ordered) + +### T1 — Types + fork-gating scaffolding **(needs U0, U5)** +Per U0: in-tree `gloastypes` package with the SIP-canonical enums/domains/structs (`GloasBeaconVote` (+SSZ, 120B), `EnvelopeConsensusData`, `BlindedExecutionPayloadEnvelope`, roles/partial-sig/domains). Add the `IsGloas` helper and the `ForkAtEpoch` Gloas entry (`networkconfig/beacon.go`). `DataVersionGloas` comes from the T2 fork patch. **Placement (migration-aligned):** land `gloastypes` as a subpackage of the eventual bridge home `protocol/v2/types` (e.g. `protocol/v2/types/gloas`), not a standalone top-level package — so the ssv-spec migration absorbs it into one node-type root without a relocation, and its M1 codemod sweeps it like any other file (migration plan §1). On `boole-fork`, `protocol/v2/types` already hosts node-side `runner_role.go`/`consensus_data.go`/`partial_sig_message.go`, so `protocol/v2/types/gloas` sits beside real protocol-type siblings (placement A confirmed). Model `IsGloas` on the **beacon** fork method (`BeaconForkAtEpoch`), not the SSV-fork `BooleForkAtEpoch` (per U5/§0); `SSVForks` is no longer empty (it has `Boole`) — decide per §0 whether Gloas needs its own entry. + +### T2 — BeaconNode abstraction + full node-side Gloas types & endpoint clients **(needs U2, U3)** +Per U2 (revised — full impl): add the U3 interface methods to `client.go`; define **full-fidelity Gloas types node-side** (in the `gloas` package — SSZ + JSON per `consensus-specs@6ebb2216c` + beacon-APIs#580) and implement the `beacon/goclient/` **endpoint clients (real HTTP)** — not placeholders. Regenerate mocks for unit tests. Tag each method by maturity tier (merged / #580 / endpoint-missing) so the churn surface is explicit. **Order:** PTC call-set first (merged endpoints → live-devnet-testable soonest), then ProposerPreferences (mock-only — no endpoint), then envelope + produceBlockV4 (#580, watch). The go-eth2-client rebase later swaps our node-side types/clients for upstream's `spec/gloas` (a dedup), not a gate. *(`DataVersionGloas` placeholder + `IsGloas` and the `GLOAS_FORK_EPOCH` wiring already landed.)* + +### T3 — §1 slot timing **— done** (uncommitted) +The key realization: ePBS retimes duties from **thirds to quarters**, and every deadline is `N × IntervalDuration` with **N preserved across the fork** (attestation/sync 1×, aggregate/contribution 2×, PTC 3×); the Gloas bps (2500/5000/7500) are exactly 1/4, 2/4, 3/4. So the whole change is one fork-gate: `(*Beacon).IntervalDuration()` → `IntervalDuration(slot)`, returning `SlotDuration/3` pre-Gloas and `SlotDuration/4` from Gloas on. Every `N × IntervalDuration` caller then lands on the right quarter automatically — scheduler attestation timer (`SlotTicker`) + head-event acceleration check, attester/proposer indices-change deadline, the aggregator-committee runner, and `goclient` aggregator + sync-contribution. **Pre-Gloas is byte-identical** (`TestNetwork` has no Gloas). The misleadingly-named `waitOneThird*`/`waitTwoThirds*` helpers were renamed `waitOneInterval*`/`waitTwoIntervals*` with fork-aware comments/logs. PTC's 75% (`PayloadAttestationCutoff` = 3/4) was already correct — untouched. Unit test `TestBeacon_IntervalDuration` (thirds before the fork, quarters from it on). + +**QBFT round-1 head-start — deliberately not retimed (2026-06-27).** `roundtimer/timer.go`'s `round1HeadStart` (`slotDuration/3` committee, `*2/3` aggregator/sync-contribution — the pre-round-1 wait for the block/attestations to arrive) is the one timing constant *not* routed through `IntervalDuration`, so it stayed at thirds post-Gloas. It is QBFT round-change *liveness* timing, not one of the SIP §1 duty deadlines (all retimed above), so retiming it is out of §1 scope; the post-Gloas misalignment (head-start 1/3 vs the now-1/4 attestation deadline) only loosens leader-rotation timing on the slow path, with no correctness impact. **Decision: leave as-is** — revisit only if consensus timing proves problematic under the tighter ePBS schedule on devnet. (Surfaced by a §1 review.) + +### T4 — §2 attestation / `GloasBeaconVote` **(needs T1, T2, U5) — scoped against Anchor (2026-06)** +**The type already exists** (T1 built `protocol/v2/types/gloas/beacon_vote.go` — `GloasBeaconVote{BlockRoot, Source, Target, AttestationDataIndex}`, 120B SSZ, field-order- and wire-identical to Anchor's `GloasBeaconVote`), referenced nowhere outside its package. So **T4 is pure wiring**, not new types. + +**Anchor's `GloasBeaconVoteValidator` = the pre-Gloas validator + exactly two rules:** (1) range-check `attestation_data_index ∈ {0,1}` (reject ≥2); (2) reconstruct the slashing-check `AttestationData` with the single QBFT-decided index. Everything else (far-future target, source) }` and passes it to the proposer's `StartEnvelopeDuty`. `c.ExecuteDuty` enqueues (async) + routes by pubkey, and `RunnerRoleForValidatorDuty` → `duty.RunnerRole()` → `RoleEnvelopeBuilder` on Gloas (post-Boole) slots, so no role-map change was needed. + - **⑥ message validation — DONE** (committed): the role-9 arm across the validation rules — `validRoleAtSlot` (Gloas-only), `maxRound`=2 + the round-spread skip + `messageLateness` (all like the proposer — QBFT, instance-relative timing), `partialSignatureTypeMatchesRole` (post-consensus only, no pre-consensus), `dutyLimit`=SlotsPerEpoch (≤1 self-build envelope/slot). `committeeRole`/`monotonicSlotRole`/`validateBeaconDuty`/`storedSlotCount` unchanged — not a committee role, not beacon-scheduled, has consensus. Per §0 no infra change (state is `MessageID`-keyed, incl. `RunnerRole`). 5 unit tests. + - **Heavy payload — DONE** (committed): the full Gloas `ExecutionPayload` (Deneb's + `block_access_list` [EIP-7928 — an opaque `ByteList`, *not* the feared nested SSZ: the EL RLP-encodes it, the CL only stores+hashes] + `slot_number` [EIP-7843]; `base_fee_per_gas` as `[32]byte` SSZ uint256), the full `ExecutionPayloadEnvelope`/`SignedExecutionPayloadEnvelope` + a `Blinded()` transform, and goclient `Get`/`SubmitExecutionPayloadEnvelope` (best-effort #580 paths, octet-stream SSZ like §4). The runner's `produceBlindedEnvelope`/`submitEnvelope` are filled (fetch→cache→blind / content-match→holder publishes). **e2e QBFT test — done** (committed): `runner/envelope_e2e_test.go` adapts the proposer's heavyweight harness, covering the post-consensus publish both via direct `submitEnvelope` (builder publishes / competing-envelope operator skips) and a full `ProcessPostConsensus` (share-signed partial-sig quorum under `DOMAIN_BEACON_BUILDER` → reconstruct → publish). **HTR-parity — done** (committed): the `ExecutionPayload` field order, `BlockAccessList` bound (`ByteList[2**30]`), and `slot_number` placement were verified field-for-field against the canonical container (consensus-specs `specs/gloas/beacon-chain.md` @ `6ebb2216c`); `TestExecutionPayloadLayoutMatchesSpec` pins the HTR as a drift guard. **Deferred (devnet-gated):** the `…Contents` blob-carrying publish body — it carries `envelope + blobs + kzg_proofs`, but there's no blob source (T7 defers the §4 `BlockContents`; the proposer caches only the bare block `cachedGloasBlockSSZ`), the #580 publish endpoint accepts *either* the bare envelope (stateful — what SSV does, working) *or* the Contents (stateless), and SSV self-build is naturally stateful (its own BN built + holds the §4 payload). So the Contents form is needed only if a devnet BN runs payload-stateless — revisit then (it also un-defers T7's blob plumbing). **TODO** — a *computational* HTR cross-check against published canonical Gloas SSZ spec vectors once the fork ships (none exist for the unreleased fork yet, so today's check is against the spec *source* + a drift guard, not vectors). +- **Recommendation when resuming:** build ④ as the first focused step (consensus-critical — don't rush it at a session tail), then ⑤/⑥, then the heavy payload last (devnet-validated). + +### T9 — Message validation for new roles **(needs U4; pairs with T5/T6/T8)** +Implement the U4 rule arms: `validRole`, `partialSignatureTypeMatchesRole`, `dutyLimit`, `messageEarliness`/`messageLateness`, `maxRound`. Dual-instance needs **no infra change** (state is `MessageID`-keyed, per §0) — just add the role arms. + +**ProposerPreferences: done** (uncommitted). Mechanical arms mirror PTC (`validRoleAtSlot`=`isInGloas`, `partialSignatureTypeMatchesRole`, `validPartialSigMsgType`, pre-consensus limit, `seen_msg_types`, no-consensus reject). Plus the **multi-future-slot reconciliation** the role forced — its signer holds the whole lookahead at once, which the per-signer state machine (built for monotonic one-slot-at-a-time) didn't fit: (1) `messageEarliness` allowance = the lookahead span (`proposerPreferencesEarlyEpochs`=2 epochs); (2) **exempt from the monotonic `ErrSlotAlreadyAdvanced` check** (`monotonicSlotRole`) — else a higher proposal slot poisons lower ones (devnet-frequent); (3) **`messageLateness` past bound** (replaces the dropped monotonic replay protection); (4) **per-signer ring sized to the lookahead** (`storedSlotCount(role)`) so concurrent lookahead slots don't collide and per-slot dedup stays exact. 7 unit tests. +**Spam hardening: done** (uncommitted). Per-epoch `dutyLimit` arm (`SlotsPerEpoch`) + a duty-assignment check in `validateBeaconDuty` (the proposal slot must be a real assignment via `dutyStore.Proposer`, with RANDAO-style tolerance — accept while the slot's epoch is unfetched, since the duty fetch may be in flight). Layered with the earliness/lateness window + committee membership + the runner's expected-root check. + +### T10 — Runner registration & wiring **(needs U0; T5, T6, T8 runners)** +Register the new roles in `SetupRunners` (`operator/validator/controller.go`, `runnersType` + `switch`). **Correction (verified building T5):** the node duty→role map `RunnerRoleForValidatorDuty(duty, isBooleFork)` needs **no** new arm — its Boole branch already returns `duty.RunnerRole()`, which ssv-spec maps for the new BN roles (the same path PTC uses); and per-role queues **auto-create** from the registered `DutyRunners` (`validator.go:~81` ranges `options.DutyRunners`), so no `validator.go` change. **ProposerPreferences: done** (registered with `FeeRecipientProvider`=validatorStore + `GasLimit`; PTC already registered). **Pending:** EnvelopeProposer (T8). + +### T11 — Fork cutover & transition **(needs T3–T8)** +`GLOAS_FORK_EPOCH` is fetched from the BN at runtime (external, ~Q3 2026; lives in `Beacon.Forks[DataVersionGloas]`, gated by `(*Beacon).IsGloas`); the ValidatorRegistration→ProposerPreferences switchover at the boundary; the pre-fork preferences emission window; transition tests (boundary epoch where old + new coexist). **Mirror Boole's transition machinery rather than inventing it:** `InBooleTransitionWindow` / `inBoolePriorWindow` / `inBooleSubsequentWindow` (`networkconfig/network.go`, with `boolePriorWindowEpochs` / `booleSubsequentWindowSlots`) is the direct template for both the Gloas cutover and the pre-fork ProposerPreferences emission window (U4/T5). Develop/test on devnets until a public testnet schedules the fork. +- **VR deprecation: done** (committed). Gloas-gated stop at all three points: `validRoleAtSlot(RoleValidatorRegistration)` rejects Gloas-or-later slots; the VR duty handler skips emission (periodic + event enqueue); the periodic `VRSubmitter` stops submitting. Added `networkconfig.TestNetworkWithGloas(epoch)` test fixture (TestNetwork has no Gloas fork) + 2 tests. The VR runner stays registered but goes idle (handler emits nothing). +- **Pre-fork emission: done** (uncommitted). `Beacon.GloasForkEpoch()` + `Network.InGloasPriorWindow(slot)` (mirrors `inBoolePriorWindow`, `gloasPriorWindowEpochs = MIN_SEED_LOOKAHEAD = 1`); the prefs handler's per-tick logic extracted to `emitForTick`, which in the prior window pre-emits the first Gloas epoch's preferences (`emitForEpoch(epoch+1)`); 3 tests. **So T11 = done** (cutover both directions). Remaining: end-to-end boundary testing belongs with the broader e2e effort. +- **Cleanup (carry here):** `DefaultGasLimit` and the `feeRecipientProvider` interface live in `protocol/v2/ssv/runner/validator_registration.go` but are now also consumed by `proposer_preferences.go` (the runner). Since this fork deprecates the ValidatorRegistration runner, relocate both to a neutral file (e.g. `runner.go`) as part of the cutover so removing VR doesn't orphan them. Compiler-caught, not silent — safe to defer to here. + +### T12 — Testing **(per track + integration + e2e)** +Unit tests per runner/handler/validation arm (against T2 mocks); fork-boundary tests; **e2e on a local Gloas devnet** — the full-impl decision (U2) makes this the real acceptance bar. **e2e vehicle — ssv-mini** (`github.com/ssvlabs/ssv-mini`): the SSV-labs Kurtosis stack already runs Lighthouse + Geth + the SSV layer (4 operators, validators, contracts via Hardhat) at the **Boole** fork. **Prerequisite (a task in the ssv-mini repo):** bump its Ethereum layer from **Fulu → a Glamsterdam/Gloas-capable Lighthouse + Geth** with Glamsterdam fork params (mirror the ethpandaops `glamsterdam-devnets` configs); then run the Gloas SSV implementation e2e against it. Until ssv-mini gains Gloas, unit tests + pointing SSV at an external ethpandaops Glamsterdam devnet cover it. `SignedProposerPreferences` stays unit/mock-only (no endpoint anywhere). **Spectests:** Gloas has no ssv-spec vectors, so the node-side Gloas types add no spec-test surface — the broader migration owns spectest decoupling, not ePBS. + +### T13 — Telemetry / metrics / logging / docs **(uses U6; parallel)** +U6 metrics (Blinded-split local-build counter; PTC/prefs reconstruction-miss counters); structured logs for new duties; operator docs for new config. + +--- + +## 4. Dependency & sequencing + +``` +Inputs (resolved unless noted): + U0 ssv-spec strategy -> feeds T1, T7, T10 (resolved: migrate off ssv-spec) + U1 §6 QBFT vs no-QBFT -> feeds T8 (RESOLVED — QBFT) + U2/U3 go-eth2-client+iface -> feeds T2 + U4 msg-validation model -> feeds T5, T6, T9 + U5 fork-gating (+slashing) -> feeds T1, T4 + U6 telemetry signal -> feeds T13 + +Implementation ("X <- Y" = X depends on Y): + T1 <- U0, U5 + T2 <- U2, U3 + T3 <- T1 + T4 <- T1, T2, U5 + T5 <- T1, T2, U4 (publish step: mock-only until upstream endpoint) + T6 <- T1, T2, U4 + T7 <- U0, T1, T2 (#580-pinned) + T8 <- T1, T2, T7 (ship last; #580-pinned; U1 resolved -> QBFT) + T9 <- U4 (alongside T5/T6/T8) + T10 <- U0, T5, T6, T8 (U0 values + the new runners) + T11 <- T3..T8 (fork cutover; external GLOAS_FORK_EPOCH) + T12 per-track + integration + T13 <- U6 (telemetry; parallel throughout) +``` + +**Suggested order:** **T1 + T2** (foundations; T2 is now full node-side Gloas types + real endpoint clients, e2e-testable on a live devnet) → **T3, T7** → **T4** → **T5, T6** → **T9/T10** woven in → **T8** (after U1) → **T11/T12** → **T13** throughout. + +**Two completion lines (and the migration handoff).** ePBS finishes along two separate axes — don't conflate them: +- **Node-side complete (full impl):** T1, T3–T6, T9, T10, plus the T7/T8 structure — built on the node-side Gloas types + real `beacon/goclient` endpoint clients (T2), unit-tested against mocks. Independent of the go-eth2-client rebase. +- **Live-devnet validated:** e2e against a local Gloas devnet (ssv-mini once Gloas-bumped, or an external Glamsterdam devnet — T12). The real interop bar; mock-green ≠ interop-green. `SignedProposerPreferences` is the lone gap (no endpoint anywhere), so it stays unit/mock-only. + +The go-eth2-client rebase is a separable, later **dedup** (swap our node-side types/clients for upstream's `spec/gloas`) — not an integration gate; it can land any time, even post-fork. + +The ssv-spec migration's handoff gates on **node-side-complete** (including T8's node-side structure, since T8 ships last and the migration's repo-wide codemod can't run concurrently with T8's QBFT/message-validation edits), *not* on the go-eth2-client dedup: the codemod begins once ePBS's node-side tracks merge, and the dedup (separable, possibly post-fork) does not gate it (see migration plan §6). + +--- + +## 5. Decisions log + +| ID | Decision | Owner | Affects | Status | +|----|----------|-------|---------|--------| +| U0 | Node-side Gloas types (SIP values); **migrate off ssv-spec entirely (end-state)** — ePBS adds types node-side, never modifies/bumps/PRs ssv-spec; **on `boole-fork`: T7/T10 extend Boole's existing node-side switches** (`consensus_data.go`/`runner_role.go`), consolidation is now baseline (not a bump); **lock Anchor constants early** (not end-of-execution) | node + Anchor coord | T1, T7, T10 | **resolved** | +| U1 | §6 QBFT vs no-QBFT(sign-all) → **QBFT** | SIP #94 maintainer (GalRogozinski), 2026-06-23 | T8 | **resolved** (QBFT; faulty-leader handling + no new message class outweigh no-QBFT's latency/Byzantine edge for a rare path) | +| U2/U3 | **Full node-side impl**: build Gloas types + endpoint clients now (e2e-testable on a live devnet via ssv-mini); mocks for unit tests; upstream go-eth2-client rebase = later **dedup**, not a gate | node | T2 | **resolved** (revised — full impl) | +| U4 | Msg-validation model; ProposerPreferences carries `proposal_slot` (= `duty.Slot`), future-slot allowed via a role-specific `messageEarliness` exemption (T9) | node | T5, T6, T9 | **resolved** | +| U5 | Gate Gloas by beacon epoch; slashing needs no change. Post-Boole: `SSVForks` now has `Boole` (pre-Boole "empty struct" basis corrected) — Gloas stays beacon-gated; re-pin `spec.go`/`beacon.go` anchors | node | T1, T4 | **resolved** | +| U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters | node | T13 | **resolved** | +| — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | open (implement node-side vs #580; pin + watch; e2e on devnet) | +| — | `SignedProposerPreferences` publish endpoint | upstream | T5 | open (mock-only; no e2e until it exists) | +| — | `GLOAS_FORK_EPOCH` schedule | Ethereum | T11 | external (Glamsterdam ~Q3 2026; devnets now) | +| — | Anchor wire-constant lock | node + Anchor | T1 | **PTC verified vs sigp/anchor `epbs` (matches); domains = consensus-specs = #632**; §5/§6 not in Anchor yet — re-check when added | +| — | go-eth2-client upstream Gloas + fork rebase | upstream | T2 | optional **dedup** — we implement node-side now; swap for upstream `spec/gloas` when it ships | +| ssv-mini e2e | Adopt ssv-mini (Kurtosis: Lighthouse+Geth+SSV) as the Gloas e2e vehicle; prerequisite = bump its Eth layer Fulu→Glamsterdam | node + ssv-mini repo | T12 | **resolved** (direction) | +| Boole | **Baseline = `boole-fork` (canonical Boole branch)**; lands on stage in **~2-3 weeks (≈ mid-July 2026, ground truth)**. **Decision: ePBS starts now off `boole-fork`** (parallel independent testing, can't wait) + `rebase --onto stage` at landing (§6). consolidation/role-6/`ProposerConsensusData`/`SSVForks{Boole}`/transition-windows live on `boole-fork` until then | node | all tracks | **resolved** | + +--- + +## 6. Branch & rebase workflow (ePBS starts now off `boole-fork`) + +**Decision: start ePBS now off `boole-fork`, in parallel with Boole's finalization.** ePBS must begin immediately for independent development + testing — and that's achievable now: the *node-side-complete-on-mocks* milestone (T2 BeaconNode mocks; see §4) doesn't gate on Boole landing or upstream, so ePBS can be built and exercised against mocks/devnet while Boole is being finalized. *(The alternative — wait ~2-3 weeks and build off post-Boole stage — was simpler but rejected: ePBS can't wait.)* + +**Don't build off pre-Boole `stage`:** ePBS lives in the exact files Boole heavily changes (`operator/duties/`, the committee/aggregator runners + `value_check.go`, `message/validation/*`, controller `SetupRunners`, the node-side `protocol/v2/types/{runner_role,consensus_data}.go`). *(Note `beacon/goclient/spec.go` / `networkconfig/beacon.go` are only small diffs — see U5.)* Branch off `boole-fork`. Building on `stage` and rebasing across the Boole merge later is a *rewrite*, not a rebase — every hunk on T4–T10 conflicts against Boole-restructured code, and you'd build to the pre-Boole design (re-creating the `runner_role.go`/`consensus_data.go` switches Boole already has). + +**Path:** +1. **Branch off a `boole-fork` tip now.** You get the correct baseline immediately — `RoleAggregatorCommittee=6`, `ProposerConsensusData`, the node-side switches to extend, `SSVForks{Boole}` + transition-window machinery to mirror. Build it right the first time. +2. **Track `boole-fork` until it lands (~2-3 weeks).** It's ~46 behind stage (Apr 2026), so you're developing on **Apr-stage**; `boole-fork` will be refreshed against stage before/at landing, so merge its updates into the ePBS branch as they appear and **re-run the independent tests after absorbing the refresh** (the Apr→Jun reconciliation can touch your files). Bounded by the ~2-3 week horizon, then `rebase --onto stage` at landing. +3. **When Boole merges to `stage`, move only the ePBS commits onto stage:** + ``` + git rebase --onto stage + ``` + This replays just the ePBS commits (skipping Boole's, now in stage). Clean whether Boole→stage was a **merge/FF** (Boole's commits are literally in stage) or a **squash** (the `--onto` form sidesteps the duplicated-commit conflicts a plain `git rebase stage` would hit). + +**Coordinate one thing:** nudge whoever merges Boole→stage toward a **merge-commit or fast-forward over a squash** — then even a plain rebase is clean and `--onto` is just insurance. + +**What this unblocks now:** branched off `boole-fork`, essentially all node-side ePBS work (T1, T3–T7, T9, T10, T13) can start immediately on the correct baseline. The only still-blocked items are blocked *regardless of Boole*: T8 (U1 design call) and the upstream go-eth2-client rebase / #580 endpoints (external). + +**Accepted cost:** starting now means ePBS eats a rebase across the Boole landing (the 46-commit stage reconciliation + the merge) and a re-validation pass after absorbing `boole-fork`'s refresh. That's the deliberate price of getting independent ePBS testing going in parallel — bounded by the ~2-3 week landing horizon, and the `--onto` mechanics keep the final move mechanical. + +**First incremental rebase — done** (validates the approach — small surface, not a big-bang). `boole-fork` was refreshed (#2899 + #2900); ePBS rebased onto it with two reconciliations: **scheduler.go** — boole-fork's new `dutySlotIsExecutionSlot` lateness guard was *combined* with the PTC cutoff baseline (the guard is true for PTC, so it doesn't replace it); **ptc_attester.go** — boole-fork renamed the runner completion API (`finishDuty`/`ErrRunningDutyFinished` → `markDuty*`/`ErrRunningDutySucceeded`), so abstains now use `markDutyNotRequired`. go.mod/go.sum auto-merged cleanly; #2900 also fixed a pre-existing Electra aggregate-index test. The `rebase --onto stage` at landing (step 3) still applies. + +--- + +## §7 — ePBS e2e Execution Plan (active; node-side complete) + +PTC is implemented node-side end-to-end (wire types → goclient endpoints → ekm signing → `PTCAttesterRunner` → `SetupRunners` registration → scheduler handler with the 75% trigger → message validation; ssv-spec ePBS constants via PR ssvlabs/ssv-spec#632, go.mods pinned to its commit). This supersedes the T12 sketch with the concrete plan. + +**Committed on `epbs-gloas`** (rebased onto the refreshed `boole-fork` — see §6): the PTC implementation (above); two review rounds — first the `DataVersionGloas` → `networkconfig` / `BeaconForkAtEpoch` TODO / SSZ-regen tidy-up, then the 11-point PTC code review (unmasked-address requests, per-client timeouts, transient-BN warn, cutoff-baselined lateness, `signSSZRoot`, abstain semantics, handler tests); the `GlamsterdamDevnet` networkconfig stub; a `.dockerignore` `tla/` exclusion. **P1 image `ssvnode:epbs-gloas` builds + runs** (verified). + +### Gate check — PASSED +Make-or-break question for the public-devnet path: do the Gloas devnet CL clients expose the **beacon-API PTC validator endpoints**? (A Gloas chain can run with built-in VCs doing PTC internally without exposing them to an external VC like SSV.) They do: +- **Lodestar** `packages/api/src/beacon/routes/validator.ts` defines `getPtcDuties` (`/eth/v1/validator/duties/ptc/{epoch}`) and `producePayloadAttestationData` (→ `gloas.PayloadAttestationData`) — the exact URLs `beacon/goclient/ptc.go` calls. **Lighthouse** has the endpoints in `common/eth2` + a `payload_attestation_service`. +- `ethpandaops/glamsterdam-devnets` runs purpose-built Gloas images of every major client (`lighthouse`, `lodestar`, `prysm`, `teku`, `grandine`, …). **Live devnet = devnet-5** (`GLOAS_FORK_EPOCH: 30`, Gloas active ~20 days; chain `7095321190`, genesis `1780577940`). **devnet-3/4 are torn down** (dashboards 404 — the repo README's status table is stale, still shows devnet-3 🟢); no devnet-6+. Probe `https://glamsterdam-devnet-N.ethpandaops.io/` (→ 200) to find the live one — don't trust the README. + +→ SSV operators pointed at a Lodestar/Lighthouse Gloas-devnet BN can run the full duties→produce→submit PTC flow. **Track 1 is feasible now.** + +### Shared prerequisites +- **P1 — PTC node image: DONE.** `ssvnode:epbs-gloas` builds + runs (verified). **No `GOPRIVATE` needed** for the build — the branch-pinned ssv-spec is in both go.sums, so `go mod download && go mod verify` resolves it via the public proxy without the sum-DB (GOPRIVATE is only for `go get`/`tidy`). Keep `tla/` out of the build context (`.dockerignore`) or local TLA+ scratch bloats `COPY . .`. +- **P2 — ssv-spec #632 merged** → re-point both go.mods at the cut version (drops the branch-pin; `go get`/`tidy` then no longer need `GOPRIVATE`). +- **P3 — DONE.** The deferred refinements: handler dependent-root/reorg refresh (committed) · PTC message lateness TTL + per-validator duty-count cap (committed) · PTC duty-assignment check (uncommitted) — a `dutyStore.PTC` (`Duties[gloas.PTCDuty]`) entry, with the handler reworked to broad-record every participating validator's duty in both operator+exporter modes (mirrors proposer/sync; `InCommittee` marks this node's own for execution) and an `IsEpochSet`-tolerant `RolePTCAttester` arm in `validateBeaconDuty`. Behavior is now sound under real-network reorgs/timing. +- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on devnet-5 before adding a distinct non-convergence outcome. + +### Track 1 — public glamsterdam-devnet (startable now; critical path) +1. **Devnet = devnet-5** (the live one; devnet-3/4 are down). **Sanity-check first:** `GET /eth/v1/config/spec` (confirm `GLOAS_FORK_EPOCH` is past) + a PTC endpoint on a devnet-5 BN. Pull config (genesis time/root, fork schedule, chain ID, deposit contract, EL/BN endpoints + basic-auth) from `glamsterdam-devnets/network-configs/devnet-5/` + `config.glamsterdam-devnet-5.ethpandaops.io/api/v1/nodes/inventory`. +2. **`networkconfig` entry — DONE** (committed): `GlamsterdamDevnetSSV` in `networkconfig/glamsterdam-devnet.go` — an `&SSV{}` only (the Beacon side comes from the BN at runtime; no `&Network{}` needed), registered + selectable as `glamsterdam-devnet` (domain `{0,0,9,0}`, `Boole:0`). **Fill 3 `TODO(e2e)` fields after steps 3-4:** `RegistryContractAddr` + `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs), `TotalEthereumValidators` (approx count). +3. **Deploy SSV contracts** on the devnet EL; register 4 operators. +4. **Validators** — deposit via the devnet faucet/deposit contract → await activation → split keys into shares → register validators+shares on the SSV contract. +5. **Run 4 operators** (P1 image) on the devnet config; **observe** PTC: operator logs (`fetched PTC duties` → `successfully submitted payload attestation`) + the BN `payload_attestations` pool. +- Risks: devnet resets/instability; validator activation latency; SSV contract deploy on a non-standard chain; per-client beacon-API PTC completeness (Lodestar/Lighthouse confirmed — verify the specific BN combo used). + +### Track 2 — ssv-mini local (hermetic) — **IMPLEMENTED (2026-06-27); gated on review/merge** +**Correction:** the earlier claim that `ethpandaops/ethereum-package@6.1.0` "has no Gloas/Glamsterdam fork (only up to Fulu+BPO)" is **wrong**. 6.1.0's `network_params.yaml` ships `gloas_fork_epoch` (+ the §1 quarter-slot `*_due_bps_gloas` timings) and threads it through `input_parser → el_cl_genesis_generator → values.env.tmpl`; its own CI test `.github/tests/fulu-genesis.yaml` runs `fulu_fork_epoch: 0` + `gloas_fork_epoch: 2`. So a local Gloas net is configurable **today** — no upstream wait. The only real blocker was Gloas-capable client images, solved by the ethpandaops `glamsterdam-devnet-5` builds (all EL/CL clients tagged). + +Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change): +1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images, genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (logs/dora): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. +2. **ethereum2-monitor [#504](https://github.com/ssvlabs/ethereum2-monitor/pull/504)** (scoped in #503) — Gloas block decoding (go-eth2-client v0.28.x can't decode Gloas): a reactive raw-JSON fallback in `beacon.FetchBlock` — no SSZ, no shared types. Re-enables E2M attestation validation on a Gloas chain. +3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. + +### Sequencing +- **Done (codeable side):** P1 image + the `networkconfig` stub — both shared by the two tracks. +- **Next (infra, your hands):** devnet-5 sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + observe PTC. +- **Track 1** (live devnet) is the path to PTC execution today; **Track 2** (ssv-mini local) is now implemented (above) and is the hermetic/CI path. + +**Track 2 merge/enable order (don't lose the monitor re-enable — it's the one cross-repo coupling):** +1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; observe ePBS via SSV logs + dora). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. +2. **ethereum2-monitor #504** — merge; then rebuild the monitor image (ssv-mini `make prepare-monitor`, built from `../ethereum2-monitor`). +3. **ssv-mini follow-up** — once #504 is in the monitor image, flip `monitor.enabled: true` in `params-gloas.yaml`. This turns on E2M attestation validation on the Gloas chain. *(This is the easy-to-forget step — it's intentionally deferred out of #34 so #34 stays mergeable today.)* +4. **aetheria #123** — merge last; `local_testnet_gloas` then runs the full executor `(event)` flow with E2M. Its E2M capture is best-effort, so it also works between steps 1 and 3 — just without E2M validation until the monitor is re-enabled. + +Independent: aetheria #123 and ssv-mini #34 don't depend on #504 to *function* (E2M just stays skipped); #504 + step 3 only add E2M validation. The PTC/proposer/envelope ePBS behavior itself is observable from step 1 via node logs + dora. + +### PR #2855 (MEV timing games) — hold; merge ePBS first +**Decision:** do not merge #2855 for now — merge ePBS first, then reconsider #2855's role. + +Rationale: ePBS removes the *out-of-protocol* apparatus #2855's doc configures (mev-boost/commit-boost relay polling — gone post-Gloas), but **not** the underlying "select the bid as late as the deadline allows" dynamic. Post-Gloas that lever relocates: for a non-self-building proposer it moves *into SSV* (when it calls `GetGloasBeaconBlock`) and/or the BN's own bid selection, while the larger late-MEV lever moves to the builder (or SSV's §6 envelope timing when self-building). So #2855's SSV-side knob (`ProposalSoftDeadline`) likely **carries over with re-derived bounds** for the tighter 25%-of-slot (vs 33%) attestation deadline — a reduced job, not a removed one — which is what to re-evaluate once ePBS lands. + +- `ProposerDelay`: **keep, do not deprecate** — the live pre-Gloas MEV knob and, with #2855 on hold, the only one. Re-tune its role/bounds for ePBS rather than removing it. +- Docs: EXTERNAL_BUILDERS.md ePBS forward-pointer added (#2901); the MEV_CONSIDERATIONS.md full ePBS rewrite is deferred to the mainnet track. + +### ProposerDelay → ePBS split — DONE (#2901) +Fork-gated per-slot (`IsGloasAtSlot`): +- **Pre-ePBS:** `ProposerDelay` + `AllowDangerousProposerDelay` unchanged, but apply **pre-fork only** (today `ProposerDelay` fires under Gloas too — it sits before the `IsGloasAtSlot` branch in the proposer runner, so this is a real carve-out). +- **Post-ePBS:** `ProposerDelay`/`AllowDangerousProposerDelay` have no effect; a new **`ProposerDelayEPBS`** takes over with similar behavior, **hard-capped at 1000ms** (startup-rejected above it — no `AllowDangerous` override, for simplicity), **default 0** (opt-in). Tighter ~25% deadline + smaller/uncertain MEV upside ⇒ no aggressive escape hatch; default-off until devnet-5 measurements justify a value. +- Self-document the flag in #2901 (config-struct comment + `config.example.yaml`); the MEV_CONSIDERATIONS.md prose update lands later in #2855 once its shape is final. +- Proposer budget under Gloas = `ProposerDelayEPBS` + the QBFT round timer (audit ① below). NOTE (corrected): the pre-Gloas `proposalSoftTimeout` does **not** apply under Gloas — the produce path uses `firstClientResult` and bypasses it (audit ④), so there's no proposalSoftTimeout↔delay coupling to tune. + +### Gloas timing audit — full inventory (thirds → quarters) +Every hardcoded slot-relative timeout/deadline classified so none is missed when `IntervalDuration` goes thirds→quarters. Scope: duty-execution + beacon-fetch + QBFT timing. (Out of scope by nature: p2p timeouts and message-validation lateness — those are slot-*count* / fixed-margin based, not interval-fraction, so the quarters change doesn't move them.) + +**Auto-scales via `IntervalDuration` — OK, no change:** aggregation fetch (`beacon/goclient/aggregator.go:95`), sync-contribution delay (`sync_committee_contribution.go:135`), scheduler exec delays (`operator/duties/scheduler.go:362,444`), indices-change deadlines (`proposer.go:121`, `attester.go:131`), 50% aggregator mark (`aggregator_committee.go:236`), `PayloadAttestationCutoff`=3/4 (`networkconfig/beacon.go:66`). + +**Hit-list — needs Gloas adjustment:** +1. **QBFT round timer — DONE (#2901).** `round1HeadStart` now derives from `IntervalDuration` (1× committee, 2× aggregator), so head starts track quarters under Gloas; `RoundTimeout`/`EstimatedRoundAt` (+ the message-validation `estimatedRoundAt`) pass `IntervalDuration(slot)`; pre-Gloas behavior is byte-identical (interval = `slotDuration/3`, verified by unchanged test expectations + a new Gloas case). **`QuickTimeout` kept at 2s** — it's a fixed round-trip budget, not a slot fraction — so the Gloas proposer is effectively round-1-must-succeed; shrinking it to restore the round-2 fallback is deferred to devnet RTT data (documented at the `QuickTimeout` const). (`QuickTimeoutThreshold`=8 / `SlowTimeout`=2min → leave.) +2. **`weightedAttestationData{Soft,Hard}Timeout` — DONE.** Scaled proportionally to the attestation window via a new `scaleToAttestationWindow(base, slot)` helper (integer `base * 3 / intervalsPerSlot`): unchanged pre-Gloas (1/3 window), ×3/4 from Gloas (1/4 window, ~4s→3s). Applied to the hard + soft fetch budgets and their soft/2 (scoring) + soft/4 (block-header) derivatives. Assumes BN response timings are fork-independent (the stated decision); nil-guarded for pre-init. +3. **Attestation refetch — DONE.** `minTimeForRetry`/`refetchDelay`/`refetchTimeout` scaled by the same `scaleToAttestationWindow` helper (the 100ms poll ticker is fork-agnostic granularity → left). + +**Checked — not applicable / corrected:** +4. **`proposalSoftTimeout`** (`options.go:47`, default 1800ms; min 500ms) — *not* a Gloas issue. The Gloas produce path uses `firstClientResult` (`gloas_proposer.go:28`) and bypasses it (pre-Gloas-only). The real Gloas question is a *design* one — should Gloas produce do multi-BN bid comparison / a soft timeout at all? — tied to `ProposerDelayEPBS`; forward-looking, not a constant to lower. +5. **§6 envelope / PTC / proposer-preferences runners — clean.** No hardcoded slot timing; all deadline-driven (deadline injected by the scheduler/executor). + +**Fork-agnostic — leave (verified):** `commonTimeout` 5s / `longTimeout` 60s (general HTTP); `ptcHTTPClient` has no client timeout — ctx-bounded (PTC fetches use `commonTimeout`, bounded by the slot-end duty deadline; Gloas produce/submit bounded by the proposer ctx); `blockPropagationDelay` 300ms (network propagation); scheduler `slotDelay≥100ms` drift threshold (L496/553); `attest.go:430` 100ms poll granularity; `observability.go:148` 1ms log threshold; queue micro-timings (`inboxReadFrequency` 1ms, `retryDelay` 25ms, ttlcache 10min, `SlotDuration/retryDelay` retry count); slotticker (SlotDuration boundary ticker); `DefaultSlotDuration` 12s. + +**Approach:** derive the hit-list fixes from `IntervalDuration`/the Gloas deadline (one fork-scaling source of truth), matching how the §1 deadlines already work. From e51da27d1890a872fce47606865e4402733463ba Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 12:30:28 +0300 Subject: [PATCH 067/150] gloas: tighten ePBS code comments across the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only quality pass over the PR's added/changed comments — no logic changes. Surveyed the comment-heavy files; the prose was largely well-calibrated, with a handful of declutters and two stale-wording fixes: - runner.go: "voluntary-exit envelope" -> "duty" (envelope is now a distinct ePBS concept; voluntary exit has none). - timer_test.go: EstimatedRoundAt was refactored from a loop to a direct formula-inversion — drop the stale "removed early return" / "the loop" wording and name the actual `if elapsed < 0` guard. - duties/proposer_preferences.go: drop the "publication-finality hold is a deferred refinement" aside (that hold is not planned). - duties/{validator_registration,voluntary_exit}.go: remove the duplicated "shares the value 4 by coincidence" note (kept once, on the slack constant) and collapse two inline blocks that re-derived their constants' docstrings; all wire-critical warnings preserved. - timer.go: replace the six-line worked example with the two-case piecewise formula (matches EstimatedRoundAt's comment). - ptc.go, handshaker.go, gloas/execution_payload_bid.go: minor declutter. --- beacon/goclient/ptc.go | 2 +- network/peers/connections/handshaker.go | 6 ++--- operator/duties/proposer_preferences.go | 2 +- operator/duties/validator_registration.go | 11 +++----- operator/duties/voluntary_exit.go | 26 +++++-------------- protocol/v2/qbft/roundtimer/timer.go | 8 ++---- protocol/v2/qbft/roundtimer/timer_test.go | 11 ++++---- protocol/v2/ssv/runner/runner.go | 2 +- .../v2/types/gloas/execution_payload_bid.go | 2 +- 9 files changed, 22 insertions(+), 48 deletions(-) diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index bbe1479cc9..25899cd335 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -63,7 +63,7 @@ func (gc *GoClient) SubmitPayloadAttestationMessages(ctx context.Context, messag } // firstClientResult runs fn against each beacon client in turn, each under its own common-timeout -// budget, returning the first success and recording every attempt; on all failures it joins the errors. +// budget, returning the first success; on all failures it joins the per-client errors. func firstClientResult[T any](ctx context.Context, gc *GoClient, routeName, httpMethod string, fn func(ctx context.Context, addr string) (T, error)) (T, error) { var zero T var errs error diff --git a/network/peers/connections/handshaker.go b/network/peers/connections/handshaker.go index ddf93c9561..380cdb98bb 100644 --- a/network/peers/connections/handshaker.go +++ b/network/peers/connections/handshaker.go @@ -243,10 +243,8 @@ func (h *handshaker) updatePeerInfo(pid peer.ID, handshakeErr error) { // updateNodeSubnets tries to update the subnets of the given peer func (h *handshaker) updateNodeSubnets(logger *zap.Logger, pid peer.ID, ni *records.NodeInfo) { - // invariant: verifyTheirNodeInfo rejects nil-Metadata before calling this, - // so ni.Metadata is non-nil on the live handshake path. Kept as - // belt-and-suspenders in case the invariant is weakened or this helper is - // reused from another call site. + // verifyTheirNodeInfo rejects nil-Metadata before calling this, so the check + // below is defensive — covering a weakened invariant or a new call site. if ni.Metadata != nil { subnets, err := commons.SubnetsFromString(ni.Metadata.Subnets) if err == nil { diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index 77dc57dbe0..e532fb3c09 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -38,7 +38,7 @@ func (h *ProposerPreferencesHandler) WaitShutdown() {} // HandleDuties emits proposer-preferences duties across the proposer lookahead (current + next epoch, // MIN_SEED_LOOKAHEAD=1). In the epoch immediately before the Gloas fork it pre-emits the first Gloas // epoch's preferences (SIP #94 §5) so builders have them before the fork. Reorg/indices-change re-emission -// is handled below; the publication-finality hold is a deferred refinement. +// is handled below. func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.logger.Info("starting duty handler") defer h.logger.Info("duty handler exited") diff --git a/operator/duties/validator_registration.go b/operator/duties/validator_registration.go index 83392d28a5..5c2af71c68 100644 --- a/operator/duties/validator_registration.go +++ b/operator/duties/validator_registration.go @@ -38,9 +38,6 @@ const ( // // This is NOT when this operator broadcasts its own partial-sig; see // validatorRegistrationExecutionSlotsToPostpone for that. - // - // Note: shares its numeric value (4) with validatorRegistrationSchedulingSlack - // below by coincidence — the two are independent. validatorRegistrationDutySlotsToPostpone = 4 // validatorRegistrationSchedulingSlack absorbs per-operator timing @@ -147,11 +144,9 @@ func (h *ValidatorRegistrationHandler) HandleDuties(ctx context.Context) { return } - // dutySlot is the deterministic wire slot — identical across - // operators regardless of receipt time or code version — feeding the - // partial-sig envelope and the signed Timestamp's epoch. - // earliestExecutionSlot is a separate, local-only broadcast gate. See - // both constants' docstrings for the full rationale. + // dutySlot is the deterministic wire slot; earliestExecutionSlot is a + // separate, local-only broadcast gate. See both constants' docstrings + // for the full rationale. blockSlot, err := h.blockSlot(ctx, regDescriptor.BlockNumber) if err != nil { h.logger.Warn( diff --git a/operator/duties/voluntary_exit.go b/operator/duties/voluntary_exit.go index 69d12ee908..0950dc6ca1 100644 --- a/operator/duties/voluntary_exit.go +++ b/operator/duties/voluntary_exit.go @@ -33,9 +33,6 @@ const ( // // This is NOT when this operator broadcasts its own partial-sig; see // voluntaryExitExecutionSlotsToPostpone for that. - // - // Note: shares its numeric value (4) with voluntaryExitSchedulingSlack - // below by coincidence — the two are independent. voluntaryExitDutySlotsToPostpone = 4 // voluntaryExitSchedulingSlack absorbs per-operator timing variance once an @@ -49,7 +46,7 @@ const ( // — which the inbound message-validation path checks via dutyCount. // // Independent of voluntaryExitDutySlotsToPostpone despite happening to - // share the same numeric value (4); see the note on that constant. + // share the same numeric value (4). voluntaryExitSchedulingSlack = 4 // voluntaryExitExecutionSlotsToPostpone is the earliest slot, expressed as @@ -141,22 +138,11 @@ func (h *VoluntaryExitHandler) HandleDuties(ctx context.Context) { return } - // Derive dutySlot deterministically from the EL event's block slot - // so every operator arrives at the same value regardless of when - // they personally received the event, and regardless of code version. - // This matters because dutySlot feeds dutyStore (used by inbound - // message-validation's dutyCount check), the outbound partial-sig - // envelope's Slot field, and VoluntaryExit.Epoch via - // EstimatedEpochAtSlot (see VoluntaryExitRunner.calculateVoluntaryExit). - // Divergent slots — across operators or across versions — would - // either drop messages at validation or break BLS partial-signature - // aggregation near epoch boundaries, silently failing the exit. - // - // earliestExecutionSlot is a separate, local-only gate that defers - // our own broadcast until peers' EL streaming pipelines have - // plausibly caught up. The two slots are deliberately decoupled so - // this operator's wire behavior stays interoperable with pre-#2851 - // operators in mixed clusters. + // Derive dutySlot deterministically from the EL event's block slot so + // every operator arrives at the same value regardless of receipt time + // or code version; earliestExecutionSlot is a separate, local-only gate + // deferring our own broadcast until peers' EL pipelines have plausibly + // caught up. See both constants' docstrings for the full rationale. blockSlot, err := h.blockSlot(ctx, exitDescriptor.BlockNumber) if err != nil { h.logger.Warn( diff --git a/protocol/v2/qbft/roundtimer/timer.go b/protocol/v2/qbft/roundtimer/timer.go index 2804b7392e..b70c0e2598 100644 --- a/protocol/v2/qbft/roundtimer/timer.go +++ b/protocol/v2/qbft/roundtimer/timer.go @@ -32,12 +32,8 @@ var CutOffRound specqbft.Round = specqbft.Round(specqbft.CutoffRound) // roundTimeoutForRound returns the time-into-slot at which the given round will time out // (i.e. transition to round+1) for the given role: // -// Round 1 ends at headStart + 1 * quick -// Round 2 ends at headStart + 2 * quick -// ... -// Round T ends at headStart + T * quick (T = quickThreshold) -// Round T+1 ends at headStart + T * quick + 1 * slow -// Round T+2 ends at headStart + T * quick + 2 * slow +// Round r <= T: headStart + r * quick +// Round r > T: headStart + T * quick + (r - T) * slow (T = quickThreshold) // // Every role has its own dedicated headStart duration. func roundTimeoutForRound(role spectypes.RunnerRole, intervalDuration time.Duration, round specqbft.Round) time.Duration { diff --git a/protocol/v2/qbft/roundtimer/timer_test.go b/protocol/v2/qbft/roundtimer/timer_test.go index 9dcd5e4301..fc0d09a4f4 100644 --- a/protocol/v2/qbft/roundtimer/timer_test.go +++ b/protocol/v2/qbft/roundtimer/timer_test.go @@ -244,8 +244,8 @@ func TestRoundTimeoutOffsetGloasInterval(t *testing.T) { // - offset exactly → just advanced to round r+1 // - offset + 1ns → still in round r+1 // -// This is the test that would have caught an off-by-one `<` vs `<=` in EstimatedRoundAt's loop, -// or a wrong starting `r` — none of which the pre-existing tests directly exercised. +// This catches an off-by-one (`<` vs `<=`) at a round boundary or a wrong starting round — +// neither of which the pre-existing tests directly exercised. func TestEstimatedRoundAtBoundaries(t *testing.T) { // Use a realistic slot duration (12s) so the numbers line up with the real QuickTimeout (2s) and // SlowTimeout (2m) values. @@ -289,10 +289,9 @@ func TestEstimatedRoundAtBoundaries(t *testing.T) { } } -// TestEstimatedRoundAtEdgeCases covers inputs at and before slot start — the cases that the -// removed early return (`if sinceFirstRoundChange <= 0 { return FirstRound, nil }`) used to -// special-case. After the refactor the loop itself handles them; this test regression-guards -// that behavior. +// TestEstimatedRoundAtEdgeCases covers inputs at and before slot start. EstimatedRoundAt +// special-cases them with `if elapsed < 0 { return FirstRound, nil }`; this test +// regression-guards that behavior. func TestEstimatedRoundAtEdgeCases(t *testing.T) { // Use a realistic slot duration (12s) so the numbers line up with the real QuickTimeout (2s) and // SlowTimeout (2m) values. diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index 4edc1436e7..ba24218db2 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -328,7 +328,7 @@ type dutyConclusion struct { // previous duty's watcher (if still pending) reports its own duty and is reaped by its own timer. // // The deadline is the end of the current wall-clock slot rather than duty.Slot's end because some -// duties are stamped with a slot in the past (a voluntary-exit envelope carries blockSlot+4 but +// duties are stamped with a slot in the past (a voluntary-exit duty carries blockSlot+4 but // executes at blockSlot+12); for beacon duties the two coincide. func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { concluded := make(chan dutyConclusion, 1) diff --git a/protocol/v2/types/gloas/execution_payload_bid.go b/protocol/v2/types/gloas/execution_payload_bid.go index 3e4c46580f..aa8b120241 100644 --- a/protocol/v2/types/gloas/execution_payload_bid.go +++ b/protocol/v2/types/gloas/execution_payload_bid.go @@ -18,7 +18,7 @@ type BuilderIndex uint64 const BuilderIndexSelfBuild = BuilderIndex(^uint64(0)) // ExecutionPayloadBid is the Gloas (ePBS) bid the proposer commits to in the block body, replacing the -// inline execution payload of pre-Gloas blocks (consensus-specs gloas): the block carries only this +// inline execution payload of pre-Gloas blocks: the block carries only this // commitment, and the payload itself ships separately in the envelope (§6). For self-build, BuilderIndex // is BuilderIndexSelfBuild. Fields match the pinned spec / go-eth2-client PR #280 (the earlier #269 // shape — a single BlobKZGCommitmentsRoot — predates the blob-commitments-list change and is stale). From 6052da57ccbf55b1166da778cdae7538384107d5 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 13:25:45 +0300 Subject: [PATCH 068/150] =?UTF-8?q?gloas:=20plan=20=C2=A78=20fork-transiti?= =?UTF-8?q?on=20monitoring=20+=20logs-first=20observability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the in-branch plan with the fork-transition test/monitor strategy and adopt a logs-first observability principle: - §8: pre-flight + boundary + per-section watch (proven on local_testnet_gloas; generalizes to devnet-5 / Hoodi / Sepolia), plus the planned log-coverage audit (G1-G5) that adds the missing greppable logs. - §1 principle: every test/verification claim must be a greppable DEBUG log-line (all testing automatable); OTel metrics are nice-to-have viz only, never a primary validation tool. - Reframe U6/T13/§2b/§5 telemetry as nice-to-have; mark dora as a visual aid (greppable logs are the automatable signal). --- EPBS_IMPLEMENTATION_PLAN.md | 76 +++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 5d1c19936c..56db8ef760 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -52,6 +52,7 @@ Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Trea - Tracks (§3) are dependency-ordered; graph in §4. - Symbol names/anchors were verified against **pre-Boole** HEAD `82a9f4f8f`; the Boole baseline (`boole-fork`) changes some of them — often small diffs, and some seams already exist on stage (e.g. the `GLOAS_FORK_EPOCH` TODO at `spec.go:255`) — so **re-verify against `boole-fork`** at implementation start. Line numbers are approximate (`~`) and may drift. - Each new runner role touches five seams: **(a)** type/enum, **(b)** runner impl, **(c)** `SetupRunners` registration, **(d)** duty handler/trigger, **(e)** message validation. +- **Observability is logs-first (see §8).** Every test/verification claim in this plan must be expressed as a **greppable DEBUG log-line** — so all planned testing is automatable (grep the line, assert it). OTel metrics (U6/T13) are explicitly **nice-to-have** (dashboards / aggregation / visualization only) and are **never** a primary validation tool. --- @@ -109,7 +110,7 @@ Add to `protocol/v2/blockchain/beacon/client.go` (mocks regen via `//go:generate - **Slashing: no change** (see §0). **Parameterizes T1, T4.** ### U6 — Local-build / reconstruction telemetry **(decided)** -Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=false` ≈ local; a **pre-Gloas proxy** — the signal changes post-fork). PTC reconstruction-miss and ProposerPreferences reconstruction-failure: new counters in those runners. **Parameterizes T13** (and informs T8 priority). +Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=false` ≈ local; a **pre-Gloas proxy** — the signal changes post-fork). PTC reconstruction-miss and ProposerPreferences reconstruction-failure: new counters in those runners. **Parameterizes T13** (and informs T8 priority). **Logs-first (§8): these counters are nice-to-have viz only; the primary, automatable validation is the matching greppable DEBUG log (§8 G2 build-source, G3 duty-outcome, and the reconstruction-miss logs) — never the metric alone.** ### Newly confirmed (folded into tracks) - Sync-committee path inert to the new vote field → noted in T4. @@ -128,7 +129,7 @@ Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=fal | **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | | **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | | **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | -| **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13), revisit §6 priority and any no-QBFT tuning post-deploy | +| **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13) — **nice-to-have viz; primary validation is the §8 greppable logs** — revisit §6 priority and any no-QBFT tuning post-deploy | | **Anchor wire-constant lock** | Cross-client agreement; cheap now, expensive at interop | **Partially verified** (sigp/anchor `epbs` branch): PTC constants match exactly (`Role::PTCAttester=7`, `PartialSignatureKind::PTCAttester=7`, validator-scoped); domains `0x0B/0C/0D` + domain epochs (`epoch(proposal_slot)`/`epoch(data.slot)`) match consensus-specs = #632. Anchor hasn't built §5/§6 yet (PTC-first, like us) → SIP + consensus-specs are the shared reference (verified); re-check §5/§6 constants when Anchor adds them. | | **Full migration off ssv-spec** (all duty types) | Direction **decided**; execution is a separate, larger initiative | Out of scope here; ePBS adds Gloas types node-side as the first down-payment (see U0). Main cost (spectest decoupling) is the migration's, not ePBS's | @@ -244,7 +245,7 @@ Register the new roles in `SetupRunners` (`operator/validator/controller.go`, `r Unit tests per runner/handler/validation arm (against T2 mocks); fork-boundary tests; **e2e on a local Gloas devnet** — the full-impl decision (U2) makes this the real acceptance bar. **e2e vehicle — ssv-mini** (`github.com/ssvlabs/ssv-mini`): the SSV-labs Kurtosis stack already runs Lighthouse + Geth + the SSV layer (4 operators, validators, contracts via Hardhat) at the **Boole** fork. **Prerequisite (a task in the ssv-mini repo):** bump its Ethereum layer from **Fulu → a Glamsterdam/Gloas-capable Lighthouse + Geth** with Glamsterdam fork params (mirror the ethpandaops `glamsterdam-devnets` configs); then run the Gloas SSV implementation e2e against it. Until ssv-mini gains Gloas, unit tests + pointing SSV at an external ethpandaops Glamsterdam devnet cover it. `SignedProposerPreferences` stays unit/mock-only (no endpoint anywhere). **Spectests:** Gloas has no ssv-spec vectors, so the node-side Gloas types add no spec-test surface — the broader migration owns spectest decoupling, not ePBS. ### T13 — Telemetry / metrics / logging / docs **(uses U6; parallel)** -U6 metrics (Blinded-split local-build counter; PTC/prefs reconstruction-miss counters); structured logs for new duties; operator docs for new config. +U6 metrics (Blinded-split local-build counter; PTC/prefs reconstruction-miss counters) — **nice-to-have viz only (§8)**; the **primary validation surface is the structured DEBUG logs** for the new duties (every behavior greppable — see the §8 logs-first audit); operator docs for new config. --- @@ -296,7 +297,7 @@ The ssv-spec migration's handoff gates on **node-side-complete** (including T8's | U2/U3 | **Full node-side impl**: build Gloas types + endpoint clients now (e2e-testable on a live devnet via ssv-mini); mocks for unit tests; upstream go-eth2-client rebase = later **dedup**, not a gate | node | T2 | **resolved** (revised — full impl) | | U4 | Msg-validation model; ProposerPreferences carries `proposal_slot` (= `duty.Slot`), future-slot allowed via a role-specific `messageEarliness` exemption (T9) | node | T5, T6, T9 | **resolved** | | U5 | Gate Gloas by beacon epoch; slashing needs no change. Post-Boole: `SSVForks` now has `Boole` (pre-Boole "empty struct" basis corrected) — Gloas stays beacon-gated; re-pin `spec.go`/`beacon.go` anchors | node | T1, T4 | **resolved** | -| U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters | node | T13 | **resolved** | +| U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters — nice-to-have viz; §8 logs are primary | node | T13 | **resolved** | | — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | open (implement node-side vs #580; pin + watch; e2e on devnet) | | — | `SignedProposerPreferences` publish endpoint | upstream | T5 | open (mock-only; no e2e until it exists) | | — | `GLOAS_FORK_EPOCH` schedule | Ethereum | T11 | external (Glamsterdam ~Q3 2026; devnets now) | @@ -349,36 +350,46 @@ Make-or-break question for the public-devnet path: do the Gloas devnet CL client - **P1 — PTC node image: DONE.** `ssvnode:epbs-gloas` builds + runs (verified). **No `GOPRIVATE` needed** for the build — the branch-pinned ssv-spec is in both go.sums, so `go mod download && go mod verify` resolves it via the public proxy without the sum-DB (GOPRIVATE is only for `go get`/`tidy`). Keep `tla/` out of the build context (`.dockerignore`) or local TLA+ scratch bloats `COPY . .`. - **P2 — ssv-spec #632 merged** → re-point both go.mods at the cut version (drops the branch-pin; `go get`/`tidy` then no longer need `GOPRIVATE`). - **P3 — DONE.** The deferred refinements: handler dependent-root/reorg refresh (committed) · PTC message lateness TTL + per-validator duty-count cap (committed) · PTC duty-assignment check (uncommitted) — a `dutyStore.PTC` (`Duties[gloas.PTCDuty]`) entry, with the handler reworked to broad-record every participating validator's duty in both operator+exporter modes (mirrors proposer/sync; `InCommittee` marks this node's own for execution) and an `IsEpochSet`-tolerant `RolePTCAttester` arm in `validateBeaconDuty`. Behavior is now sound under real-network reorgs/timing. -- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on devnet-5 before adding a distinct non-convergence outcome. +- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on devnet-5 before adding a distinct non-convergence outcome (tracked as §8 G5). ### Track 1 — public glamsterdam-devnet (startable now; critical path) 1. **Devnet = devnet-5** (the live one; devnet-3/4 are down). **Sanity-check first:** `GET /eth/v1/config/spec` (confirm `GLOAS_FORK_EPOCH` is past) + a PTC endpoint on a devnet-5 BN. Pull config (genesis time/root, fork schedule, chain ID, deposit contract, EL/BN endpoints + basic-auth) from `glamsterdam-devnets/network-configs/devnet-5/` + `config.glamsterdam-devnet-5.ethpandaops.io/api/v1/nodes/inventory`. 2. **`networkconfig` entry — DONE** (committed): `GlamsterdamDevnetSSV` in `networkconfig/glamsterdam-devnet.go` — an `&SSV{}` only (the Beacon side comes from the BN at runtime; no `&Network{}` needed), registered + selectable as `glamsterdam-devnet` (domain `{0,0,9,0}`, `Boole:0`). **Fill 3 `TODO(e2e)` fields after steps 3-4:** `RegistryContractAddr` + `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs), `TotalEthereumValidators` (approx count). 3. **Deploy SSV contracts** on the devnet EL; register 4 operators. 4. **Validators** — deposit via the devnet faucet/deposit contract → await activation → split keys into shares → register validators+shares on the SSV contract. -5. **Run 4 operators** (P1 image) on the devnet config; **observe** PTC: operator logs (`fetched PTC duties` → `successfully submitted payload attestation`) + the BN `payload_attestations` pool. +5. **Run 4 operators** (P1 image) on the devnet config; **assert the greppable operator logs** (`fetched PTC duties` → `successfully submitted payload attestation`) as the automatable pass/fail signal; the BN `payload_attestations` pool is a secondary on-chain cross-check. - Risks: devnet resets/instability; validator activation latency; SSV contract deploy on a non-standard chain; per-client beacon-API PTC completeness (Lodestar/Lighthouse confirmed — verify the specific BN combo used). -### Track 2 — ssv-mini local (hermetic) — **IMPLEMENTED (2026-06-27); gated on review/merge** +### Track 2 — ssv-mini local (hermetic) — **IMPLEMENTED (2026-06-27); e2e PTC submission PROVEN 2026-06-28 (see result below); gated on review/merge** **Correction:** the earlier claim that `ethpandaops/ethereum-package@6.1.0` "has no Gloas/Glamsterdam fork (only up to Fulu+BPO)" is **wrong**. 6.1.0's `network_params.yaml` ships `gloas_fork_epoch` (+ the §1 quarter-slot `*_due_bps_gloas` timings) and threads it through `input_parser → el_cl_genesis_generator → values.env.tmpl`; its own CI test `.github/tests/fulu-genesis.yaml` runs `fulu_fork_epoch: 0` + `gloas_fork_epoch: 2`. So a local Gloas net is configurable **today** — no upstream wait. The only real blocker was Gloas-capable client images, solved by the ethpandaops `glamsterdam-devnet-5` builds (all EL/CL clients tagged). Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change): -1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images, genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (logs/dora): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. +1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images, genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (greppable SSV logs = the automatable signal; dora as a manual visual aid): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. 2. **ethereum2-monitor [#504](https://github.com/ssvlabs/ethereum2-monitor/pull/504)** (scoped in #503) — Gloas block decoding (go-eth2-client v0.28.x can't decode Gloas): a reactive raw-JSON fallback in `beacon.FetchBlock` — no SSZ, no shared types. Re-enables E2M attestation validation on a Gloas chain. -3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. +3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. **Plus an E2M-coordination fix (2026-06-28, committed on `epbs/local-testnet-gloas`):** when `monitor-api` is absent the orchestrator now also sets the per-flow `e2m=false` (not just leaving `E2MURL` at a stale default), so the executor *skips* E2M and the `(event)` flow passes (on-chain lifecycle only) instead of hard-failing and tearing down. ### Sequencing - **Done (codeable side):** P1 image + the `networkconfig` stub — both shared by the two tracks. -- **Next (infra, your hands):** devnet-5 sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + observe PTC. +- **Next (infra, your hands):** devnet-5 sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + verify PTC via the greppable step-5 logs. - **Track 1** (live devnet) is the path to PTC execution today; **Track 2** (ssv-mini local) is now implemented (above) and is the hermetic/CI path. **Track 2 merge/enable order (don't lose the monitor re-enable — it's the one cross-repo coupling):** -1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; observe ePBS via SSV logs + dora). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. +1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; verify ePBS via greppable SSV logs — dora as a visual aid). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. 2. **ethereum2-monitor #504** — merge; then rebuild the monitor image (ssv-mini `make prepare-monitor`, built from `../ethereum2-monitor`). 3. **ssv-mini follow-up** — once #504 is in the monitor image, flip `monitor.enabled: true` in `params-gloas.yaml`. This turns on E2M attestation validation on the Gloas chain. *(This is the easy-to-forget step — it's intentionally deferred out of #34 so #34 stays mergeable today.)* 4. **aetheria #123** — merge last; `local_testnet_gloas` then runs the full executor `(event)` flow with E2M. Its E2M capture is best-effort, so it also works between steps 1 and 3 — just without E2M validation until the monitor is re-enabled. -Independent: aetheria #123 and ssv-mini #34 don't depend on #504 to *function* (E2M just stays skipped); #504 + step 3 only add E2M validation. The PTC/proposer/envelope ePBS behavior itself is observable from step 1 via node logs + dora. +Independent: aetheria #123 and ssv-mini #34 don't depend on #504 to *function* (E2M just stays skipped); #504 + step 3 only add E2M validation. The PTC/proposer/envelope ePBS behavior itself is verifiable from step 1 via greppable node logs (dora as a visual aid). + +### Track 2 — e2e RESULT (2026-06-28): full dormant → transition → executing PROVEN on the local Gloas net +Ran `aetheria local_testnet_gloas` end-to-end (host orchestrator + seeded DB + the `params-gloas.yaml` enclave, `node/ssv:epbs-gloas`, 4 operators). The SSV node's complete ePBS PTC lifecycle was observed live across the epoch-2 fork: +- **Dormant (epoch 0–1):** `DutyScheduler` starts `PTC_ATTESTER` + `PROPOSER_PREFERENCES`; they react to validator-index changes ("re-fetching PTC duties on next tick") but execute nothing. Boole active (`/ssv//boole/*` subnets). +- **Transition (epoch 2 / slot 64):** all 4 nodes' `PTC_ATTESTER` activates → `POST /eth/v1/validator/duties/ptc/2`. The first call at the exact fork-boundary slot returns CL `500 BeaconStateError(IncorrectStateVariant)` (lighthouse devnet-5: state not yet in the Gloas variant); the node's per-slot re-fetch **retries the next slot and succeeds**. Relevant to Track 1 too: expect a one-slot 500 at a node's first Gloas slot — the existing refetch absorbs it, no code change needed. +- **Executing (epoch 5 / slot 166):** with a validator held continuously active, `🔧 executing validator duty PTC_ATTESTER-e5-s166-v64` → `GET payload_attestation_data/166` → **`✔️ successfully submitted payload attestation` on all 4 operators**. An earlier duty at slot 148 correctly **failed-safe** — CL `404 No block received` on a missed slot (~80% block production on the devnet), so a validator's one-duty-per-epoch lands within an epoch or two; this is expected, not a node bug. + +**Two corrections to the prior handoff:** +1. **The "ENCRYPTION_KEY_HASH secret" blocker was a non-issue.** The working key (`SSV-AUTOMATION-…-KEY`) was already in the aetheria main checkout's `orchestrator/.env`; the stuck session was running from a different (`/tmp`) checkout whose `.env` carried the `aetheria-encryption-key` placeholder. No team secret is needed for the local Gloas run — config-gen decrypts the seed cleanly with the in-repo key (verified by decrypting the seed ciphertext directly and by a clean live config-gen). +2. **E2M coordination fix** added to #123 (see the #123 bullet above) — without it a plain `(event)` on Gloas false-fails at bulk E2M validation even though the on-chain + PTC behavior is correct. ### PR #2855 (MEV timing games) — hold; merge ePBS first **Decision:** do not merge #2855 for now — merge ePBS first, then reconsider #2855's role. @@ -411,4 +422,45 @@ Every hardcoded slot-relative timeout/deadline classified so none is missed when **Fork-agnostic — leave (verified):** `commonTimeout` 5s / `longTimeout` 60s (general HTTP); `ptcHTTPClient` has no client timeout — ctx-bounded (PTC fetches use `commonTimeout`, bounded by the slot-end duty deadline; Gloas produce/submit bounded by the proposer ctx); `blockPropagationDelay` 300ms (network propagation); scheduler `slotDelay≥100ms` drift threshold (L496/553); `attest.go:430` 100ms poll granularity; `observability.go:148` 1ms log threshold; queue micro-timings (`inboxReadFrequency` 1ms, `retryDelay` 25ms, ttlcache 10min, `SlotDuration/retryDelay` retry count); slotticker (SlotDuration boundary ticker); `DefaultSlotDuration` 12s. +--- + +## §8 — Fork-transition monitoring + logs-first observability audit + +### Observability principle — logs-first (DEBUG-complete) +**Every ePBS behavior we care about MUST be verifiable from DEBUG logs alone.** OTel metrics are *nice-to-have* — dashboards/aggregation only, never the sole evidence a behavior happened (there are no in-repo dashboards anyway, and Track 2 was verified purely from logs). Rule: any metric that records an ePBS decision/outcome must have a matching log (DEBUG or higher) carrying the same fact. The audit below closes the cases where this doesn't yet hold. + +### Fork-transition monitoring — proven on `local_testnet_gloas`; re-apply on devnet-5 / Hoodi / Sepolia +The dormant→transition→executing flow is already PROVEN on the local Gloas net (Track 2 RESULT above, epoch-2 fork, verified from logs). This is the generalized watch layer for any fork. + +**#1 blindspot:** the Gloas fork epoch is **not** in SSV config — it is read from each BN's `/eth/v1/config/spec` (`GLOAS_FORK_EPOCH`, `beacon/goclient/spec.go:259`), **with no startup log**. If a BN doesn't schedule it / BNs disagree, the node silently stays pre-Gloas (`IsGloas=false`, `IntervalDuration` stays /3) — no error, nothing ePBS fires. → pre-flight #1 + audit **G1**. + +**Control/scale per network:** `local_testnet_gloas` (Track 2, DONE) sets the fork via `params-gloas.yaml` `gloas_fork_epoch: 2` — fully controllable. `glamsterdam-devnet-5` (Track 1) — epoch from the BN, real 512-member PTC. Hoodi/Sepolia — unscheduled today (`FarFutureEpoch`); monitor-only once they schedule Gloas (same watch, no timing control). + +**Pre-flight (T-minus a few epochs):** (1) every BN's `GLOAS_FORK_EPOCH` equal + not far-future [the node can't self-check this — G1]; (2) every BN serves the 8 Gloas routes (block produce/publish, envelope get/publish, PTC duties/data/submit, proposer-dependent-root); (3) `ProposerDelayEPBS` ≤ 1s (else boot-abort); (4) validator set actually hits proposer + PTC selections in-window; (5) baseline pre-fork (`/3`, zero ePBS roles) for a clean delta. + +**Boundary quirks (from Track 2 — expect on any fork):** the first Gloas slot may return CL `500 BeaconStateError(IncorrectStateVariant)` → the per-slot refetch absorbs it (no code change); a missed proposal slot → CL `404 No block` → PTC fails-safe, the one-duty-per-epoch lands within an epoch or two. + +**Per-section primary watch** (log = source of truth; metric in parens): +- **§1 timing** — attestation submit rate holds across `/3→/4`; red flag: `⚠️ late duty execution` bursts (PTC lateness measured from the 75% cutoff). (`ssv.cl.request.duration{route=AttestationData}`, attestation refetch counters) +- **§2 attestation** — value-check `rejecting/ignoring invalid message` with `error=` (`AttestationDataIndex>1`, GloasBeaconVote 120B-vs-112B decode). (committee `duty.outcome`) +- **§3 PTC** — `fetched PTC duties` → `✔️ successfully submitted payload attestation`; `abstaining…no beacon block` occasional-ok / constant-bad; failures `failed to fetch PTC duties` / `PTC attestation failed…`. (`scheduler.executions{PTC_ATTESTER}`, `duty.outcome{PTC_ATTESTER}`) +- **§4 proposer** — `🧊 got gloas beacon block proposal` → `✅ successfully submitted block proposal`; build-source self/external [**G2 — log gap**]. (`proposal.build_source`, `submissions.failed{proposer}`) +- **§5 prefs** — `emitted proposer preferences duties` → `proposer preferences reconstructed but publish endpoint unavailable; skipping submit` (**expected** — submit stubbed, marks `not_required`); red flag `proposer preferences failed: could not build`. (`request{route=ProposerDutiesDependentRoot}`) +- **§6 envelope** — builder: [**G4 — produce log gap**] → `✅ published execution payload envelope`; non-builder: `this operator did not build the decided envelope, skipping publication`. (`request{route=*ExecutionPayloadEnvelope}`) +- **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) + +### Log-coverage audit — PLANNED (add the missing logs; execute after sign-off) +**Goal:** make the logs-first principle hold across #2901 — every metric-recorded or decision-point ePBS behavior gets a DEBUG+ log; metrics unchanged (viz only). +**Method:** per ePBS path (the new runners, duty handlers, goclient wrappers, value-checks, fork gates) enumerate behaviors/decisions/outcomes → confirm a DEBUG log carries each → where only a metric (or nothing) does, add a log. No behavior change; logs only. + +**Confirmed gaps + proposed logs:** +- **G1 — fork activation.** No log; `IsGloas` is computed from the BN's `GLOAS_FORK_EPOCH`. → startup INFO: resolved Gloas epoch + source BN (makes pre-flight #1 self-verifying); optional one-time "entered Gloas fork at slot N" at the boundary. +- **G2 — build source (self vs external builder).** Metric `ssv.runner.proposal.build_source` only; the `🧊` log lacks it. → DEBUG on each Gloas submit (the `selfBuild(block)` bit already at `proposer.go:~541`): "self-built block" vs "external builder N". *The key ePBS proposer signal.* +- **G3 — generic duty outcome succeeded/not_required.** `watchDutyOutcome.report` (`runner.go:~339`) records the metric for all four outcomes but logs only `failed`/`stuck` (Warn). → DEBUG "duty concluded" (outcome+role) for the non-warned outcomes → fully mirrors `ssv.runner.duty.outcome`. +- **G4 — envelope produce/cache.** `produceBlindedEnvelope` (`envelope.go:277`) fetches+caches the heavy envelope unlogged. → DEBUG "building execution payload envelope" (slot, block root, Took). +- **G5 — PTC non-convergence.** Surfaces only as the generic "likely stuck" (§7 obs note). → distinct DEBUG/marker once its frequency is gauged on devnet-5. + +**Verify in the sweep (likely further gaps):** the §2 chosen vote index (EMPTY=0/FULL=1) at GloasBeaconVote build (`committee.go:~1062`); proposer-preferences pinned values (dependent_root / fee_recipient / target_gas_limit); any other metric-only ePBS fact. +**NOT gaps (already DEBUG):** BN requests (`CL request done` + `route_name`), duty fetch/emit, `🔧 executing validator duty`, failures (Warn), abstain/skip, reorg-refresh. + **Approach:** derive the hit-list fixes from `IntervalDuration`/the Gloas deadline (one fork-scaling source of truth), matching how the §1 deadlines already work. From 238e620eb1d33877a78039953fcf1a85daea12a4 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 13:30:56 +0300 Subject: [PATCH 069/150] gloas: add greppable logs for fork activation, build-source, duty outcome, envelope build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs-first observability: every ePBS behavior that was metric-only or unlogged now has a matching greppable log, so it is verifiable from logs alone. Logs only; no behavior change. - spec.go: log the resolved Gloas fork epoch at startup (Info when scheduled, Debug when not). The node reads GLOAS_FORK_EPOCH from the BN with no log, so a missing/disagreeing schedule was previously silent. - proposer.go: log the decided block's build source (self-build vs external) on each Gloas proposal — previously only an OTel counter. - runner.go: log "duty concluded" for succeeded/not_required (failed/stuck already warn), fully mirroring the duty-outcome metric in logs. - envelope.go: log when this operator builds/caches the §6 envelope. --- beacon/goclient/spec.go | 6 ++++++ protocol/v2/ssv/runner/envelope.go | 1 + protocol/v2/ssv/runner/proposer.go | 4 ++++ protocol/v2/ssv/runner/runner.go | 8 +++++--- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/beacon/goclient/spec.go b/beacon/goclient/spec.go index c5463aedbe..bf3fd106c7 100644 --- a/beacon/goclient/spec.go +++ b/beacon/goclient/spec.go @@ -274,6 +274,12 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi } } + if gloasEpoch == FarFutureEpoch { + gc.log.Debug("Gloas (ePBS) fork not scheduled by the beacon node") + } else { + gc.log.Info("Gloas (ePBS) fork scheduled", zap.Uint64("epoch", uint64(gloasEpoch))) + } + forkEpochs := map[spec.DataVersion]phase0.Fork{ spec.DataVersionPhase0: { PreviousVersion: genesisForkVersion, diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 73b71ae6f4..9acbe973b3 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -264,6 +264,7 @@ func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Log if err != nil { return fmt.Errorf("produce blinded envelope: %w", err) } + logger.Debug("built execution payload envelope", fields.Slot(slot)) r.measurements.StartConsensus() if err := r.decide(ctx, logger, slot, input, r.ValCheck); err != nil { diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 584b2523d3..653813d2f8 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -531,6 +531,10 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo return fmt.Errorf("could not decode decided gloas block: %w", err) } + logger.Debug("decided gloas block build source", + fields.Slot(cd.Duty.Slot), + zap.Bool("self_build", selfBuild(block))) + var finishErr error start := time.Now() signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index ba24218db2..b6d277ba50 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -338,11 +338,13 @@ func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { report := func(c dutyConclusion) { recordDutyOutcome(ctx, b.GetRole(), c.outcome) - if c.outcome == dutyOutcomeFailed { + switch c.outcome { + case dutyOutcomeFailed: logger.Warn("⚠️ duty failed", zap.Error(c.reason)) - } - if c.outcome == dutyOutcomeStuck { + case dutyOutcomeStuck: logger.Warn("⚠️ duty did not complete before slot end (likely stuck)") + case dutyOutcomeSucceeded, dutyOutcomeNotRequired: + logger.Debug("duty concluded", zap.String("outcome", string(c.outcome))) } } From 5b4a7e35765e70ecb06fc55935a34c117c1e8e58 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 13:32:16 +0300 Subject: [PATCH 070/150] =?UTF-8?q?gloas:=20mark=20=C2=A78=20log-coverage?= =?UTF-8?q?=20audit=20G1-G4=20done=20(logs=20shipped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EPBS_IMPLEMENTATION_PLAN.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 56db8ef760..b0aa0b1a21 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -449,7 +449,8 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **§6 envelope** — builder: [**G4 — produce log gap**] → `✅ published execution payload envelope`; non-builder: `this operator did not build the decided envelope, skipping publication`. (`request{route=*ExecutionPayloadEnvelope}`) - **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) -### Log-coverage audit — PLANNED (add the missing logs; execute after sign-off) +### Log-coverage audit — G1–G4 DONE; G5 + sweep remaining +**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-5; sweep items (§2 vote index, prefs content) still pending. **Goal:** make the logs-first principle hold across #2901 — every metric-recorded or decision-point ePBS behavior gets a DEBUG+ log; metrics unchanged (viz only). **Method:** per ePBS path (the new runners, duty handlers, goclient wrappers, value-checks, fork gates) enumerate behaviors/decisions/outcomes → confirm a DEBUG log carries each → where only a metric (or nothing) does, add a log. No behavior change; logs only. From 20d9adf2a863084c901e49d221e7f12615fa2767 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 14:05:47 +0300 Subject: [PATCH 071/150] =?UTF-8?q?gloas:=20log=20=C2=A72=20vote=20index?= =?UTF-8?q?=20+=20=C2=A75=20proposer-preferences=20inputs=20(greppable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the logs-first sweep — the two remaining metric-only / unlogged ePBS decision points now emit greppable DEBUG logs (logs only; no behavior change): - committee.go: log the chosen payload-status index (0=EMPTY/1=FULL) when building the Gloas attestation vote (SIP #94 §2). - proposer_preferences.go: log the pinned inputs (dependent_root, fee_recipient, target_gas_limit) when an operator builds its preference — the values quorum converges on (SIP #94 §5). - plan doc: mark the §8 sweep done (G5 non-convergence still deferred). --- EPBS_IMPLEMENTATION_PLAN.md | 6 +++--- protocol/v2/ssv/runner/committee.go | 3 +++ protocol/v2/ssv/runner/proposer_preferences.go | 6 ++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index b0aa0b1a21..23c4a7be4f 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -449,8 +449,8 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **§6 envelope** — builder: [**G4 — produce log gap**] → `✅ published execution payload envelope`; non-builder: `this operator did not build the decided envelope, skipping publication`. (`request{route=*ExecutionPayloadEnvelope}`) - **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) -### Log-coverage audit — G1–G4 DONE; G5 + sweep remaining -**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-5; sweep items (§2 vote index, prefs content) still pending. +### Log-coverage audit — G1–G4 + sweep DONE; G5 deferred +**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-5. Sweep DONE: §2 `built gloas attestation vote`+`payload_status_index`; §5 `built proposer preferences`+`dependent_root`/`fee_recipient`/`target_gas_limit`. **Goal:** make the logs-first principle hold across #2901 — every metric-recorded or decision-point ePBS behavior gets a DEBUG+ log; metrics unchanged (viz only). **Method:** per ePBS path (the new runners, duty handlers, goclient wrappers, value-checks, fork gates) enumerate behaviors/decisions/outcomes → confirm a DEBUG log carries each → where only a metric (or nothing) does, add a log. No behavior change; logs only. @@ -461,7 +461,7 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **G4 — envelope produce/cache.** `produceBlindedEnvelope` (`envelope.go:277`) fetches+caches the heavy envelope unlogged. → DEBUG "building execution payload envelope" (slot, block root, Took). - **G5 — PTC non-convergence.** Surfaces only as the generic "likely stuck" (§7 obs note). → distinct DEBUG/marker once its frequency is gauged on devnet-5. -**Verify in the sweep (likely further gaps):** the §2 chosen vote index (EMPTY=0/FULL=1) at GloasBeaconVote build (`committee.go:~1062`); proposer-preferences pinned values (dependent_root / fee_recipient / target_gas_limit); any other metric-only ePBS fact. +**Sweep (done):** §2 chosen vote index (EMPTY=0/FULL=1) → `built gloas attestation vote`; proposer-preferences pinned values → `built proposer preferences`. Standing check: any other metric-only ePBS fact gets a matching log. **NOT gaps (already DEBUG):** BN requests (`CL request done` + `route_name`), duty fetch/emit, `🔧 executing validator duty`, failures (Warn), abstain/skip, reorg-refresh. **Approach:** derive the hit-list fixes from `IntervalDuration`/the Gloas deadline (one fork-scaling source of truth), matching how the §1 deadlines already work. diff --git a/protocol/v2/ssv/runner/committee.go b/protocol/v2/ssv/runner/committee.go index 936fdaece9..f4e6df84f7 100644 --- a/protocol/v2/ssv/runner/committee.go +++ b/protocol/v2/ssv/runner/committee.go @@ -1185,6 +1185,9 @@ func (r *CommitteeRunner) executeDuty(ctx context.Context, logger *zap.Logger, d } input = gloasVote r.ValCheck = ssv.NewGloasVoteChecker(r.signer, slot, r.attestingValidators, gloasVote) + logger.Debug("built gloas attestation vote", + fields.Slot(slot), + zap.Uint64("payload_status_index", uint64(attData.Index))) } else { vote := &spectypes.BeaconVote{ BlockRoot: attData.BeaconBlockRoot, diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 7faee670d8..f3e95db5ff 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -344,6 +344,12 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger // dependent_root, fee recipient, gas limit) reach quorum. r.proposerPreferences = preferences + logger.Debug("built proposer preferences", + fields.Slot(proposalSlot), + zap.String("dependent_root", preferences.DependentRoot.String()), + fields.FeeRecipient(preferences.FeeRecipient[:]), + zap.Uint64("target_gas_limit", preferences.TargetGasLimit)) + msg, err := signBeaconObject(ctx, r, r.NetworkConfig, validatorDuty, preferences, proposalSlot, phase0.DomainType(spectypes.DomainProposerPreferences)) if err != nil { return fmt.Errorf("could not sign proposer preferences: %w", err) From 9533fc7f182fc8a3413dca6a6bc205db50f72626 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 28 Jun 2026 15:22:13 +0300 Subject: [PATCH 072/150] gloas: fix golangci-lint findings on the ePBS PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 8 lint findings (in PR-touched code) addressed; verified clean locally with the repo-pinned linter (go tool -modfile=tool.mod golangci-lint): - beacon/goclient {gloas_proposer,ptc}.go: check resp.Body.Close (errcheck), using the repo's `defer func() { _ = ...Close() }()` pattern. - message/validation/common_checks.go: guard len(indices)==0 -> ErrNoValidators before indexing indices[0] in the non-committee role checks (gosec G602) — also closes a real empty-slice panic on a malformed message. - protocol/v2/ssv/runner/proposer.go: "cancelled" -> "canceled" (misspell). - proposer_preferences_test.go, ptc_attester_test.go: drop the redundant embedded BaseRunner from the selector (staticcheck QF1008). --- beacon/goclient/gloas_proposer.go | 2 +- beacon/goclient/ptc.go | 2 +- message/validation/common_checks.go | 6 ++++++ protocol/v2/ssv/runner/proposer.go | 2 +- protocol/v2/ssv/runner/proposer_preferences_test.go | 2 +- protocol/v2/ssv/runner/ptc_attester_test.go | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index c7fec2ebb4..974881cc99 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -85,7 +85,7 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte) if err != nil { return nil, fmt.Errorf("%s %s: %w", method, url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 25899cd335..bd127b1782 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -150,7 +150,7 @@ func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, bod if err != nil { return fmt.Errorf("%s %s: %w", method, url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 2d9e0e10e8..0c5a48b6e5 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -168,6 +168,12 @@ func (mv *messageValidator) validateBeaconDuty( ) error { epoch := mv.netCfg.EstimatedEpochAtSlot(slot) + // The non-committee role checks below index indices[0]; reject a message carrying no validator + // indices (every duty has at least one validator). + if len(indices) == 0 { + return ErrNoValidators + } + // Rule: For a proposal duty message, we check if the validator is assigned to it if role == spectypes.RoleProposer { // Tolerate missing duties for RANDAO signatures during the first slot of an epoch, diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 653813d2f8..3a37f86f90 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -69,7 +69,7 @@ type ProposerRunner struct { // startEnvelopeDuty starts the §6 envelope-signing duty for a slot, called after a self-build §4 // block is published. Injected by the controller; nil pre-Gloas / when the envelope runner is absent. // It must dispatch async with a node-scoped context: the caller runs on the proposer's post-consensus - // path, whose context is cancelled once the block duty ends. + // path, whose context is canceled once the block duty ends. startEnvelopeDuty func(slot phase0.Slot) } diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index b4996df265..75acaa063a 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -32,7 +32,7 @@ func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).BaseRunner.RunnerRoleType) + require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).RunnerRoleType) } // Regression for the monotonic ShouldProcessNonBeaconDuty reject (runner.go): a validator can hold diff --git a/protocol/v2/ssv/runner/ptc_attester_test.go b/protocol/v2/ssv/runner/ptc_attester_test.go index 92770e99c8..dd68c359e9 100644 --- a/protocol/v2/ssv/runner/ptc_attester_test.go +++ b/protocol/v2/ssv/runner/ptc_attester_test.go @@ -23,7 +23,7 @@ func TestNewPTCAttesterRunner_RequiresSingleShare(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, spectypes.RolePTCAttester, r.(*PTCAttesterRunner).BaseRunner.RunnerRoleType) + require.Equal(t, spectypes.RolePTCAttester, r.(*PTCAttesterRunner).RunnerRoleType) } // The runner validates and aggregates incoming partial signatures against its own frozen From c607ab2a2c1f155d722fa543df35aebcc1ed5add Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 29 Jun 2026 17:35:36 +0300 Subject: [PATCH 073/150] =?UTF-8?q?gloas:=20address=20ePBS=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20fan-out=20publish,=20envelope=20duty=20check,=20?= =?UTF-8?q?ctx=20+=20config=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish fan-out: SubmitGloasBeaconBlock and SubmitExecutionPayloadEnvelope now broadcast to all configured beacon nodes via multiClientSubmit (matching SubmitProposal and SubmitPayloadAttestationMessages) instead of stopping at the first client. Re-publishing a signed block/envelope to multiple BNs is safe (they dedupe by root); first-success risked a missed proposal when the first BN accepts but is slow to gossip or partitioned. Produce/GET paths stay on firstClientResult. Envelope duty-assignment check: validateBeaconDuty now requires RoleEnvelopeBuilder messages to carry a real proposer assignment (mirroring proposer-preferences, IsEpochSet-guarded) instead of falling through to a pass. The self-build envelope rides the proposer's slot, so without this a peer could push envelope-role messages for slots where the validator holds no proposal duty. Dependent-root singleflight: the collapsed ProposerDutiesDependentRoot GET runs on a detached context (WithoutCancel + a fresh common-timeout), so a leader caller's cancellation no longer fails concurrent waiters that hold later proposal-slot deadlines. Unconfigured-network guard: SSVConfigByName errors on a zero registry contract address instead of letting a placeholder devnet config silently sync from 0x0 and find no validators. PTC abstention: document the BN zero-root abstain contract and the silent-misclassification caveat, noting the abstention is already observable on the ssv.runner.duty.outcome metric (no new counter). --- beacon/goclient/gloas_envelope.go | 13 +++++++++---- beacon/goclient/gloas_proposer.go | 13 +++++++++---- beacon/goclient/proposer_preferences.go | 12 ++++++++---- message/validation/common_checks.go | 10 ++++++++++ networkconfig/ssv.go | 6 ++++++ protocol/v2/ssv/runner/ptc_attester.go | 6 ++++++ 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index d9b6546a60..656e9af8c3 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -27,16 +27,21 @@ func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0 }) } -// SubmitExecutionPayloadEnvelope publishes a signed §6 envelope as SSZ. +// SubmitExecutionPayloadEnvelope publishes a signed §6 envelope as SSZ to all configured beacon +// nodes concurrently, succeeding if at least one accepts it. Re-publishing a signed envelope to +// multiple BNs is safe — they dedupe by block root. func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { body, err := signed.MarshalSSZ() if err != nil { return fmt.Errorf("marshal signed execution payload envelope: %w", err) } - _, err = firstClientResult(ctx, gc, "SubmitExecutionPayloadEnvelope", http.MethodPost, func(ctx context.Context, addr string) (struct{}, error) { - return struct{}{}, submitExecutionPayloadEnvelope(ctx, addr, body) + + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + return gc.multiClientSubmit(ctx, "SubmitExecutionPayloadEnvelope", func(ctx context.Context, client Client) error { + return submitExecutionPayloadEnvelope(ctx, gc.clientAddresses[client], body) }) - return err } // requestExecutionPayloadEnvelope GETs the produce endpoint and decodes the SSZ response into an envelope. diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 974881cc99..9e65bb0ddb 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -31,16 +31,21 @@ func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, g }) } -// SubmitGloasBeaconBlock publishes a signed Gloas (ePBS) block as SSZ. +// SubmitGloasBeaconBlock publishes a signed Gloas (ePBS) block as SSZ to all configured beacon +// nodes concurrently, succeeding if at least one accepts it. Re-publishing a signed block to +// multiple BNs is safe — they dedupe by block root. func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { body, err := block.MarshalSSZ() if err != nil { return fmt.Errorf("marshal signed gloas block: %w", err) } - _, err = firstClientResult(ctx, gc, "SubmitGloasBeaconBlock", http.MethodPost, func(ctx context.Context, addr string) (struct{}, error) { - return struct{}{}, submitGloasBeaconBlock(ctx, addr, body) + + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + return gc.multiClientSubmit(ctx, "SubmitGloasBeaconBlock", func(ctx context.Context, client Client) error { + return submitGloasBeaconBlock(ctx, gc.clientAddresses[client], body) }) - return err } // requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index bc38aad1ea..d996fa9a06 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -20,11 +20,15 @@ import ( func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { // Several proposer-preferences runners (one per local proposing validator in the epoch) request the // same epoch's dependent_root concurrently; collapse that burst into a single GET. Not TTL-cached so - // a reorg re-emission still observes a fresh root. The collapsed call adopts the winning caller's - // ctx, so its cancellation also fails the concurrent waiters — acceptable as they share the slot's - // deadline window and a re-emit recovers. + // a reorg re-emission still observes a fresh root. The collapsed call runs on a detached context so + // the leader caller's cancellation is not propagated to the concurrent waiters — they may have + // later deadlines (different proposal slots), and a leader with a tight budget must not cancel a + // waiter that still had room. An outer common-timeout still bounds the detached call. root, err, _ := gc.proposerDutiesDependentRootInflight.Do(epoch, func() (phase0.Root, error) { - return firstClientResult(ctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { + detached := context.WithoutCancel(ctx) + dctx, cancel := context.WithTimeout(detached, gc.commonTimeout) + defer cancel() + return firstClientResult(dctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { var resp struct { DependentRoot string `json:"dependent_root"` } diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 0c5a48b6e5..1c9c8ae2af 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -203,6 +203,16 @@ func (mv *messageValidator) validateBeaconDuty( } } + // The self-build envelope rides the proposer's slot, so it must carry a real proposer assignment — + // guarded by IsEpochSet like proposer-preferences, since the message can arrive before the epoch's + // duties are fetched. + if role == spectypes.RoleEnvelopeBuilder { + validatorIndex := indices[0] + if mv.dutyStore.Proposer.IsEpochSet(epoch) && mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { + return ErrNoDuty + } + } + // Rule: For a sync committee aggregation duty message, we check if the validator is assigned to it if role == ssvtypes.RoleSyncCommitteeContribution { period := mv.netCfg.EstimatedSyncCommitteePeriodAtEpoch(epoch) diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index e1cb1a4d97..1d7d5e0ec9 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -27,6 +27,12 @@ var supportedSSVConfigs = map[string]*SSV{ func SSVConfigByName(name string) (*SSV, error) { if network, ok := supportedSSVConfigs[name]; ok { + // A zero registry contract address means the config is a placeholder (e.g. a devnet whose + // contract hasn't been deployed yet). Syncing from it would silently read an empty registry at + // 0x0 and find no validators, so fail loudly rather than start against a misconfigured network. + if network.RegistryContractAddr == (ethcommon.Address{}) { + return nil, fmt.Errorf("network %q is not fully configured: zero registry contract address", name) + } return network, nil } diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index 5f0d308ccc..c209d5ab7a 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -163,6 +163,12 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, r.markDutyFailed(err) return nil } + // BN contract: an all-zero BeaconBlockRoot is the beacon node signaling "no block for this slot" + // (the SIP #94 §3 abstain trigger) — we sign and submit nothing. The abstention is still counted: + // markDutyNotRequired concludes the duty as dutyOutcomeNotRequired, which watchDutyOutcome records + // on the ssv.runner.duty.outcome metric (labeled by role), so PTC abstentions are observable there. + // Caveat: a BN that erroneously returns a zero root would be silently misclassified as a benign + // abstention rather than a fault — we cannot distinguish the two from the root alone. if data.BeaconBlockRoot == (phase0.Root{}) { logger.Debug("abstaining from PTC attestation: no beacon block for slot", fields.Slot(slot)) r.markDutyNotRequired() From ba4266e0019c587e03875c60cf1d400ebb459437 Mon Sep 17 00:00:00 2001 From: iurii Date: Mon, 29 Jun 2026 18:20:15 +0300 Subject: [PATCH 074/150] =?UTF-8?q?gloas:=20review=20cleanups=20=E2=80=94?= =?UTF-8?q?=20detach=20attestation=20singleflight=20ctx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attestation singleflight: the collapsed GetAttestationData fetch now runs on a detached context (context.WithoutCancel), mirroring domainDataReqInflight, so the leader caller's cancellation no longer fails the other callers whose requests were collapsed into it. The underlying multi-client fetch keeps its own timeout. (The fixture relocation this change originally carried is not needed on stage, which already keeps the fixRunnerForRun chain in the non-test runner_fixture.go.) --- beacon/goclient/attest.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beacon/goclient/attest.go b/beacon/goclient/attest.go index be6f7bc3eb..2519c63cbd 100644 --- a/beacon/goclient/attest.go +++ b/beacon/goclient/attest.go @@ -71,12 +71,17 @@ func (gc *GoClient) GetAttestationData(ctx context.Context, slot phase0.Slot) (* return cachedResult.Value(), nil } - attData, err := gc.fetchAttestationData(ctx, slot) + // Detach from the leader caller's ctx so its cancellation doesn't fail the other callers + // whose requests were collapsed into this one (mirrors domainDataReqInflight); the + // underlying multi-client fetch carries its own timeout. + fetchCtx := context.WithoutCancel(ctx) + + attData, err := gc.fetchAttestationData(fetchCtx, slot) if err != nil { return nil, err } - attData, stale := gc.verifyAndRefetchIfStale(ctx, slot, attData) + attData, stale := gc.verifyAndRefetchIfStale(fetchCtx, slot, attData) // Not caching stale data allows next caller to retry with fresh fetch. if !stale { gc.attestationDataCache.Set(slot, attData, ttlcache.DefaultTTL) From 5c95232eb5971f98249a37d004dc10eb72942133 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 30 Jun 2026 12:50:12 +0300 Subject: [PATCH 075/150] =?UTF-8?q?gloas:=20address=20ePBS=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20doc-comment=20clarifications=20+=20plan=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment/doc-only response to a #2901 review (most findings were non-issues or over-stated — assessed in the plan); the actionable bits: - beacon_block.go: note blob KZG commitments also leave the body (the payload and blobs ship in the §6 envelope) — they were missing from the drops list. - ptc.go: clarify the hand-rolled client's missing custom-TLS matches the main eth2clienthttp path (system-CA https + basic-auth), so it's no regression. - plan §2: devnet-verify that a Gloas BN accepts the Fulu-tagged attestation submission (BeaconForkAtEpoch caps at Fulu; TODO(gloas) to extend if rejected). - plan §2b: record the remote-signer limitation — Web3Signer has no PTC / proposer-preferences / envelope sign types; bounded by f, local-sign workaround; operator-facing. --- EPBS_IMPLEMENTATION_PLAN.md | 3 +++ beacon/goclient/ptc.go | 8 ++++---- protocol/v2/types/gloas/beacon_block.go | 9 +++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 23c4a7be4f..bb0cc2ee98 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -127,6 +127,7 @@ Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=fal | **go-eth2-client Gloas support** | Absent upstream | Build full Gloas types + endpoint clients **node-side now** (T2); swap for upstream `spec/gloas` as a later **dedup** when it ships — not a gate | | **produceBlockV4 + envelope endpoints** | beacon-APIs#580 unmerged, may churn | Implement node-side against #580; pin + watch for churn; e2e on the local Gloas devnet (T2/T7/T8) | | **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | +| **Remote-signer (Web3Signer) ePBS duties** | Web3Signer has no PTC / proposer-preferences / envelope sign types | `RemoteKeyManager` returns a descriptive error per domain (`ssvsigner/ekm/remote_key_manager.go`); a remote-signing operator can't sign the three new duties — **bounded by `f`** (cluster reconstructs while ≤ f are remote-signing), but those operators must **local-sign the affected validators** and will emit recurring `⚠️ duty failed` noise until then. Operator-facing — surface in the PR description / operator notes. `TODO(gloas)`: route via Web3Signer when it adds the types. | | **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | | **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | | **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13) — **nice-to-have viz; primary validation is the §8 greppable logs** — revisit §6 priority and any no-QBFT tuning post-deploy | @@ -164,6 +165,8 @@ The key realization: ePBS retimes duties from **thirds to quarters**, and every **⚠️ Devnet-verify (T4 / §2) — the BN-supplied attestation index.** The Gloas vote's `AttestationDataIndex` is taken from `GetAttestationData`'s returned `attData.Index`. SSV requests `produce_attestation_data` with `CommitteeIndex: 0` (`beacon/goclient/attest.go`), so this is correct **only if the Gloas BN computes and returns the payload-status index** in the response — per `gloas/validator.md` `get_attestation_data` (`index` = 0 for a same-slot attestation, else 0=EMPTY / 1=FULL by payload status). Not verifiable without a real Gloas BN. **On devnet, confirm the response `index` is the payload-status value, not an echo of the requested committee index and not a constant 0** — otherwise every operator's vote carries index 0 (EMPTY) and FULL payloads are never attested. Fork detection does **not** depend on this: it uses `IsGloas`, not `GetAttestationData`'s returned `DataVersion` (a hardcoded Phase0 placeholder, correctly ignored). +**⚠️ Devnet-verify (T4 / §2) — the submitted attestation's version tag.** On Gloas slots the regular committee attestation is constructed + submitted with `dataVersion = BeaconForkAtEpoch(...)`, which caps at **Fulu** (`networkconfig/beacon.go` `TODO(gloas)` — the version list stops at Fulu, `IsGloas` is the side gate). Gloas reuses the Electra/Fulu attestation wire format (the §2 change is index *semantics*, not structure), so a Gloas BN **should** accept the `Eth-Consensus-Version: fulu` tag — but this is unverified; if a BN rejects it, *every* attestation fails on Gloas (High, not Low). **On devnet, confirm a Gloas BN accepts the Fulu-tagged attestation submission on Gloas slots; if not, extend `BeaconForkAtEpoch` to return `DataVersionGloas` (the existing `TODO(gloas)`).** + ### T5 — §5 ProposerPreferences **(needs T1, T2, U4)** Non-QBFT validator-scoped duty (template: `validator_registration.go` runner + VR submitter batching). - **Duty handler** (`operator/duties/`): emit for `get_upcoming_proposal_slots`; re-emit on dependent-root change (proposer `dutyFetchIntents` pattern); **pre-fork emission** for first-Gloas-epoch slots in the `MIN_SEED_LOOKAHEAD` epoch(s) before the fork. diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index bd127b1782..fea2725a48 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -29,10 +29,10 @@ const ( consensusVersionGloas = "gloas" ) -// ptcHTTPClient issues the hand-rolled PTC requests; per-call deadlines come from the request -// context. Basic-auth embedded in the (unmasked) beacon address is applied by net/http; custom -// TLS/client-cert transport is not — acceptable for this interim surface, to be retired with the -// go-eth2-client rebase. +// ptcHTTPClient issues the hand-rolled PTC requests; per-call deadlines come from the request context. +// Basic-auth in the (unmasked) beacon address is applied by net/http; custom TLS/client-cert is not — but +// the main eth2clienthttp path doesn't configure it either (system-CA https + basic-auth only), so no +// regression. Interim surface, retired with the go-eth2-client rebase. var ptcHTTPClient = &http.Client{} // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch, from diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go index c4a59219ac..6d56b2f867 100644 --- a/protocol/v2/types/gloas/beacon_block.go +++ b/protocol/v2/types/gloas/beacon_block.go @@ -22,10 +22,11 @@ type PayloadAttestation struct { Signature phase0.BLSSignature `ssz-size:"96"` } -// BeaconBlockBody is the Gloas (ePBS) block body. Versus Electra it drops the inline execution payload -// and execution requests and adds SignedExecutionPayloadBid (the payload commitment), PayloadAttestations -// (the previous slot's PTC aggregate), and ParentExecutionRequests. Field order/tags match the pinned -// spec / go-eth2-client PR #280; everything else reuses the existing fork types. +// BeaconBlockBody is the Gloas (ePBS) block body. Versus Electra it drops the inline execution payload, +// execution requests, and blob KZG commitments (the payload and blobs now ship in the §6 envelope) and +// adds SignedExecutionPayloadBid (the payload commitment), PayloadAttestations (the previous slot's PTC +// aggregate), and ParentExecutionRequests. Field order/tags match the pinned spec / go-eth2-client +// PR #280; everything else reuses the existing fork types. type BeaconBlockBody struct { RANDAOReveal phase0.BLSSignature `ssz-size:"96"` ETH1Data *phase0.ETH1Data From a85a91685ffb6a25153cf519d5378010b8754c66 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 30 Jun 2026 14:11:00 +0300 Subject: [PATCH 076/150] gloas: retarget ePBS devnet plan to glamsterdam-devnet-6; document env-var run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §7: split local_testnet and devnet into two independent, parallel initiatives (drop the Track-1-critical-path framing); add a devnet progress checklist and an operator env-var run-config (own EL/CL, cleanenv, local signing). - Refresh all live-devnet facts devnet-5 -> devnet-6, verified live 2026-06-30: chain 7052886157, genesis 1782386940, GLOAS_FORK_EPOCH 30 (active), ~3909 active validators, PTC duties route 200, open Prysm BN + geth EL endpoints (per-client BNs need basic-auth). - networkconfig/glamsterdam-devnet.go: header -> devnet-6 and TotalEthereumValidators=3909 (verified); RegistryContractAddr/SyncOffset and Bootnodes stay TODO (need contract deploy + operator setup). --- EPBS_IMPLEMENTATION_PLAN.md | 84 ++++++++++++++++++++--------- networkconfig/glamsterdam-devnet.go | 18 ++++--- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index bb0cc2ee98..86f51570d8 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ > **⚠️ ACTION — delete this file before [#2901](https://github.com/ssvlabs/ssv/pull/2901) is marked ready for review.** It is committed (rather than kept local) only as a temporary shared home for in-flight ePBS context. Before flipping #2901 to *ready for review*, move **all** still-useful / unfinished action items (e.g. the devnet e2e steps, the devnet-verify items, the upstream-gated follow-ups, the MEV_CONSIDERATIONS.md rewrite, the ProposerPreferences publish-finality follow-up) into the PR description, then remove this file in the same PR. Nothing here should outlive the PR. -**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged on the live devnet-5; PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the `…Contents` blob-carrying publish body (deferred — devnet-gated) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. +**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged for the live devnet (now devnet-6); PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the `…Contents` blob-carrying publish body (deferred — devnet-gated) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. **Baseline — the Boole fork (`boole-fork` is canonical).** ePBS builds on the SSV **Boole** protocol fork (successor to Alan): ssv-spec bumped `v1.2.2 → v1.2.3-pseudo`; `RoleAggregatorCommittee=6` with deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps; `SSVForks{ Boole }` + transition-window machinery; proposer round-robin; `lowestHash` topic→subnets. **Boole already shipped slices of this plan:** the node-side switches ePBS planned now exist — `protocol/v2/types/runner_role.go` (`RunnerRoleForValidatorDuty(duty, isBooleFork)`, fork-aware) and `protocol/v2/types/consensus_data.go` (version-switched extraction over `spectypes.ProposerConsensusData`). So **T7/T10 extend those, they don't author wrappers.** **`boole-fork` has not yet landed on stage** (stage is still `ssv-spec v1.2.2` / `SSVForks struct{}`; `boole-fork` is ~46 commits behind stage, tip Apr 2026) — but it's **expected to merge within ~2-3 weeks (≈ mid-July 2026), treated as ground truth** (see §2b). The build baseline is **`boole-fork`** (verify anchors against it). **Decision: ePBS starts now off `boole-fork`** — it must begin immediately for independent development + testing (against T2 mocks/devnet, which doesn't gate on Boole landing), so it can't wait for the merge; it rebases onto stage when Boole lands (~2-3 weeks) — see §6. The pre-Boole HEAD pin (`82a9f4f8f`) and §0/U findings are pre-Boole; corrections are inline where they flip (U0/U5/T7/T10/T11). @@ -345,38 +345,74 @@ PTC is implemented node-side end-to-end (wire types → goclient endpoints → e ### Gate check — PASSED Make-or-break question for the public-devnet path: do the Gloas devnet CL clients expose the **beacon-API PTC validator endpoints**? (A Gloas chain can run with built-in VCs doing PTC internally without exposing them to an external VC like SSV.) They do: - **Lodestar** `packages/api/src/beacon/routes/validator.ts` defines `getPtcDuties` (`/eth/v1/validator/duties/ptc/{epoch}`) and `producePayloadAttestationData` (→ `gloas.PayloadAttestationData`) — the exact URLs `beacon/goclient/ptc.go` calls. **Lighthouse** has the endpoints in `common/eth2` + a `payload_attestation_service`. -- `ethpandaops/glamsterdam-devnets` runs purpose-built Gloas images of every major client (`lighthouse`, `lodestar`, `prysm`, `teku`, `grandine`, …). **Live devnet = devnet-5** (`GLOAS_FORK_EPOCH: 30`, Gloas active ~20 days; chain `7095321190`, genesis `1780577940`). **devnet-3/4 are torn down** (dashboards 404 — the repo README's status table is stale, still shows devnet-3 🟢); no devnet-6+. Probe `https://glamsterdam-devnet-N.ethpandaops.io/` (→ 200) to find the live one — don't trust the README. +- `ethpandaops/glamsterdam-devnets` runs purpose-built Gloas images of every major client (`lighthouse`, `lodestar`, `prysm`, `teku`, `grandine`, …). **Live devnet = devnet-6** (as of 2026-06-30): `GLOAS_FORK_EPOCH: 30`, chain `7052886157`, genesis `1782386940` (≈ 2026-06-25 11:29 UTC) ⇒ **Gloas/ePBS active since ≈ 2026-06-25 14:41 UTC**. **devnet-5 is now Off; devnet-0..4 torn down** (dashboards 404). Devnets reset frequently and the repo README status table lags — **probe `https://glamsterdam-devnet-N.ethpandaops.io/` (→ 200) to find the live one, and re-check before every run** (it already moved 5→6). -→ SSV operators pointed at a Lodestar/Lighthouse Gloas-devnet BN can run the full duties→produce→submit PTC flow. **Track 1 is feasible now.** +→ SSV operators pointed at a Lodestar/Lighthouse Gloas-devnet BN can run the full duties→produce→submit PTC flow. **The `devnet` initiative is feasible now.** ### Shared prerequisites - **P1 — PTC node image: DONE.** `ssvnode:epbs-gloas` builds + runs (verified). **No `GOPRIVATE` needed** for the build — the branch-pinned ssv-spec is in both go.sums, so `go mod download && go mod verify` resolves it via the public proxy without the sum-DB (GOPRIVATE is only for `go get`/`tidy`). Keep `tla/` out of the build context (`.dockerignore`) or local TLA+ scratch bloats `COPY . .`. - **P2 — ssv-spec #632 merged** → re-point both go.mods at the cut version (drops the branch-pin; `go get`/`tidy` then no longer need `GOPRIVATE`). - **P3 — DONE.** The deferred refinements: handler dependent-root/reorg refresh (committed) · PTC message lateness TTL + per-validator duty-count cap (committed) · PTC duty-assignment check (uncommitted) — a `dutyStore.PTC` (`Duties[gloas.PTCDuty]`) entry, with the handler reworked to broad-record every participating validator's duty in both operator+exporter modes (mirrors proposer/sync; `InCommittee` marks this node's own for execution) and an `IsEpochSet`-tolerant `RolePTCAttester` arm in `validateBeaconDuty`. Behavior is now sound under real-network reorgs/timing. -- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on devnet-5 before adding a distinct non-convergence outcome (tracked as §8 G5). +- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on the live devnet (devnet-6) before adding a distinct non-convergence outcome (tracked as §8 G5). -### Track 1 — public glamsterdam-devnet (startable now; critical path) -1. **Devnet = devnet-5** (the live one; devnet-3/4 are down). **Sanity-check first:** `GET /eth/v1/config/spec` (confirm `GLOAS_FORK_EPOCH` is past) + a PTC endpoint on a devnet-5 BN. Pull config (genesis time/root, fork schedule, chain ID, deposit contract, EL/BN endpoints + basic-auth) from `glamsterdam-devnets/network-configs/devnet-5/` + `config.glamsterdam-devnet-5.ethpandaops.io/api/v1/nodes/inventory`. -2. **`networkconfig` entry — DONE** (committed): `GlamsterdamDevnetSSV` in `networkconfig/glamsterdam-devnet.go` — an `&SSV{}` only (the Beacon side comes from the BN at runtime; no `&Network{}` needed), registered + selectable as `glamsterdam-devnet` (domain `{0,0,9,0}`, `Boole:0`). **Fill 3 `TODO(e2e)` fields after steps 3-4:** `RegistryContractAddr` + `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs), `TotalEthereumValidators` (approx count). +### Two independent initiatives — run in parallel +`local_testnet` and `devnet` are **separate, independent validation initiatives** — neither blocks the other, and we want both running in parallel. They share only the build foundation (P1–P3 above: the `ssvnode:epbs-gloas` image + branch) and the greppable-log pass/fail signal; past that they diverge entirely (own networks, own client images, own validator sets, own lifecycle). +- **`local_testnet`** — hermetic, fully controlled; we own the whole validator set ⇒ **PTC duty hits every slot**. Already IMPLEMENTED + PROVEN. The fast inner-loop + CI signal — answers *"is our code correct?"*. +- **`devnet`** — the public ethpandaops multi-client net; **real 512-member PTC** against clients we don't control. The external-interop bar; needs infra (contract deploy + validators). Answers *"does it interop?"*. + +Do both: mock-green ≠ local-green ≠ interop-green. + +### Initiative `devnet` (Track 1) — public glamsterdam-devnet +1. **Devnet = devnet-6** (the live one as of 2026-06-30; devnet-5 now Off — **re-probe before each run**, see Gate check). **Sanity-check first:** `GET /eth/v1/config/spec` (confirm `GLOAS_FORK_EPOCH` is past the current epoch) + hit a PTC endpoint on a devnet-6 BN. Pull config (genesis time/root, fork schedule, chain ID, deposit contract, EL/BN endpoints + basic-auth) from `glamsterdam-devnets/network-configs/devnet-6/` + `config.glamsterdam-devnet-6.ethpandaops.io/api/v1/nodes/inventory`. + - **✅ Verified 2026-06-30:** devnet-6 live at epoch ~1118 (Gloas active ~1088 epochs). **Open read endpoints (no auth):** CL `https://beacon.glamsterdam-devnet-6.ethpandaops.io` (Prysm), EL `https://rpc.glamsterdam-devnet-6.ethpandaops.io` (chainId `0x1a462808d` = `7052886157`). **Per-client BNs** `bn---1.srv.glamsterdam-devnet-6.ethpandaops.io` (+ EL `rpc-…`) need **basic-auth** (401 without — creds from the ethpandaops devnet spec, or run your own / use the open Prysm BN). Spec matches the PR's wire values: `GLOAS_FORK_EPOCH:30`, `PTC_SIZE:512`, `PAYLOAD_ATTESTATION_DUE_BPS:7500` (=75% cutoff), `MAX_PAYLOAD_ATTESTATIONS:4`, domains `PTC_ATTESTER:0x0c`/`BEACON_BUILDER:0x0b`/`PROPOSER_PREFERENCES:0x0d`. `POST /eth/v1/validator/duties/ptc/{epoch}` → **200** (route live). **~3909 active validators** ⇒ PTC picks 512/slot, so each validator hits PTC ~every 8 slots (~90s) — frequent even with a handful of SSV validators (revises the earlier "PTC is rare on a shared net" caveat for devnet-6). +2. **`networkconfig` entry — committed + refreshed (2026-06-30):** `GlamsterdamDevnetSSV` in `networkconfig/glamsterdam-devnet.go` — an `&SSV{}` only (the Beacon side — genesis, fork schedule incl. `GLOAS_FORK_EPOCH` — comes from the BN at runtime; no `&Network{}` needed), registered + selectable as `glamsterdam-devnet` (domain `{0,0,9,0}`, `Boole:0`). Header now points at **devnet-6** (`7052886157` / `1782386940`) and `TotalEthereumValidators` is filled from the verified live count (**3909**). **Still `TODO(e2e)`** (devnet-specific, reset on every devnet; fill after steps 3-4): `RegistryContractAddr` + `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs). 3. **Deploy SSV contracts** on the devnet EL; register 4 operators. 4. **Validators** — deposit via the devnet faucet/deposit contract → await activation → split keys into shares → register validators+shares on the SSV contract. 5. **Run 4 operators** (P1 image) on the devnet config; **assert the greppable operator logs** (`fetched PTC duties` → `successfully submitted payload attestation`) as the automatable pass/fail signal; the BN `payload_attestations` pool is a secondary on-chain cross-check. - Risks: devnet resets/instability; validator activation latency; SSV contract deploy on a non-standard chain; per-client beacon-API PTC completeness (Lodestar/Lighthouse confirmed — verify the specific BN combo used). -### Track 2 — ssv-mini local (hermetic) — **IMPLEMENTED (2026-06-27); e2e PTC submission PROVEN 2026-06-28 (see result below); gated on review/merge** +### Progress checklist +**`local_testnet` initiative:** ✅ **DONE** — implemented + PTC submission PROVEN 2026-06-28 (see RESULT below); gated only on PR review/merge (#34 / #504 / #123). + +**`devnet` initiative:** +- [x] **1 · Pick & verify the live devnet** — devnet-6 verified live 2026-06-30 (see step 1): epoch ~1118 (Gloas active), PTC route 200, open BN/EL endpoints recorded, spec wire-values match the PR. +- [x] **2 · `networkconfig` stub refreshed** — `glamsterdam-devnet.go` header → devnet-6 + `TotalEthereumValidators=3909` (verified). Remaining fields tracked in step 5. +- [ ] **3 · Deploy SSV contracts on the devnet-6 EL + register 4 operators** → record `RegistryContractAddr` + deployment block. +- [ ] **4 · Validators** — deposit (devnet deposit contract `0x00000000219ab540356cBB839Cbe05303d7705Fa`) → await activation → split keys into 4 shares → register validators+shares on the SSV contract. +- [ ] **5 · Fill remaining stub TODOs** — `RegistryContractAddr` + `RegistrySyncOffset` (from 3), `Bootnodes` (operator ENRs from 3). +- [ ] **6 · Run 4 operators** — `Network: glamsterdam-devnet`, `BeaconNodeAddr`=devnet-6 CL, `ETH1Addr`=devnet-6 EL (WS). +- [ ] **7 · Verify PTC** — grep all 4 operators: `Gloas (ePBS) fork scheduled` → `fetched PTC duties` → `✔️ successfully submitted payload attestation`; abstain only on missed slots; cross-check the BN `payload_attestations` pool. + +### `devnet` — operator run config (env vars; own EL/CL, no config file) +Config is `cleanenv`-based: a node started without `--config` reads purely from env (`ReadEnv`), and env overrides a file when one is passed — so the whole operator can be driven by env vars. Minimal set per operator (we run our own EL/CL): + +| Env var | Value / note | +|---|---| +| `NETWORK` | `glamsterdam-devnet` (selects `GlamsterdamDevnetSSV`; beacon genesis/fork schedule incl. `GLOAS_FORK_EPOCH` are read from the BN) | +| `BEACON_NODE_ADDR` | **required** — your CL HTTP URL(s); **Lighthouse/Lodestar preferred** for the full PTC endpoint set; `;`-separated for multiple | +| `ETH_1_ADDR` | **required** — your EL **WS** URL(s); `;`-separated for multiple | +| `OPERATOR_KEY` | this operator's private key (or `PRIVATE_KEY_FILE`). **Local signing only** — ssv-signer / Web3Signer PTC signing is unsupported (bounded by f), so don't use the remote-signer path for this test | +| `DB_PATH` | per-operator DB dir (default `./data/db`) | +| `LOG_LEVEL` | `debug` — the PTC/fork logs are the pass/fail signal | +| `NETWORK_PRIVATE_KEY` | optional P2P identity (auto-generated if unset); set one per operator to get stable ENRs for the stub's `Bootnodes` | +| `METRICS_API_PORT` / `EVENTS_PATH` | optional (metrics port; local-events injection) | + +Once the 4 nodes are up, harvest their ENRs into the stub `Bootnodes` (or pin a known `NETWORK_PRIVATE_KEY` per node) so the cluster discovers itself. + +### Initiative `local_testnet` (Track 2) — ssv-mini / aetheria local Gloas net — **IMPLEMENTED (2026-06-27); e2e PTC submission PROVEN 2026-06-28 (see result below); gated on review/merge** **Correction:** the earlier claim that `ethpandaops/ethereum-package@6.1.0` "has no Gloas/Glamsterdam fork (only up to Fulu+BPO)" is **wrong**. 6.1.0's `network_params.yaml` ships `gloas_fork_epoch` (+ the §1 quarter-slot `*_due_bps_gloas` timings) and threads it through `input_parser → el_cl_genesis_generator → values.env.tmpl`; its own CI test `.github/tests/fulu-genesis.yaml` runs `fulu_fork_epoch: 0` + `gloas_fork_epoch: 2`. So a local Gloas net is configurable **today** — no upstream wait. The only real blocker was Gloas-capable client images, solved by the ethpandaops `glamsterdam-devnet-5` builds (all EL/CL clients tagged). Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change): -1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images, genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (greppable SSV logs = the automatable signal; dora as a manual visual aid): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. +1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images (Gloas-capable; the local net **pins these independently of whichever public devnet is live** — bump only if a later devnet build carries a client fix you need), genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (greppable SSV logs = the automatable signal; dora as a manual visual aid): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. 2. **ethereum2-monitor [#504](https://github.com/ssvlabs/ethereum2-monitor/pull/504)** (scoped in #503) — Gloas block decoding (go-eth2-client v0.28.x can't decode Gloas): a reactive raw-JSON fallback in `beacon.FetchBlock` — no SSZ, no shared types. Re-enables E2M attestation validation on a Gloas chain. 3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. **Plus an E2M-coordination fix (2026-06-28, committed on `epbs/local-testnet-gloas`):** when `monitor-api` is absent the orchestrator now also sets the per-flow `e2m=false` (not just leaving `E2MURL` at a stale default), so the executor *skips* E2M and the `(event)` flow passes (on-chain lifecycle only) instead of hard-failing and tearing down. -### Sequencing -- **Done (codeable side):** P1 image + the `networkconfig` stub — both shared by the two tracks. -- **Next (infra, your hands):** devnet-5 sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + verify PTC via the greppable step-5 logs. -- **Track 1** (live devnet) is the path to PTC execution today; **Track 2** (ssv-mini local) is now implemented (above) and is the hermetic/CI path. +### Sequencing — the two initiatives run in parallel (no cross-dependency) +- **Shared foundation (done):** P1 image + the `networkconfig` stub — the *only* thing both initiatives share; after it they proceed independently. +- **`local_testnet` (DONE — keep green):** implemented + PROVEN (below). Re-run on each branch tip / in CI as the fast regression signal; owns its own client images + validator set. +- **`devnet` (infra, your hands — start in parallel):** devnet-6 probe + sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + verify PTC via the greppable logs. Does **not** depend on `local_testnet`; can start any time. -**Track 2 merge/enable order (don't lose the monitor re-enable — it's the one cross-repo coupling):** +**`local_testnet` merge/enable order (don't lose the monitor re-enable — it's the one cross-repo coupling):** 1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; verify ePBS via greppable SSV logs — dora as a visual aid). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. 2. **ethereum2-monitor #504** — merge; then rebuild the monitor image (ssv-mini `make prepare-monitor`, built from `../ethereum2-monitor`). 3. **ssv-mini follow-up** — once #504 is in the monitor image, flip `monitor.enabled: true` in `params-gloas.yaml`. This turns on E2M attestation validation on the Gloas chain. *(This is the easy-to-forget step — it's intentionally deferred out of #34 so #34 stays mergeable today.)* @@ -384,10 +420,10 @@ Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-ch Independent: aetheria #123 and ssv-mini #34 don't depend on #504 to *function* (E2M just stays skipped); #504 + step 3 only add E2M validation. The PTC/proposer/envelope ePBS behavior itself is verifiable from step 1 via greppable node logs (dora as a visual aid). -### Track 2 — e2e RESULT (2026-06-28): full dormant → transition → executing PROVEN on the local Gloas net +### `local_testnet` — e2e RESULT (2026-06-28): full dormant → transition → executing PROVEN on the local Gloas net Ran `aetheria local_testnet_gloas` end-to-end (host orchestrator + seeded DB + the `params-gloas.yaml` enclave, `node/ssv:epbs-gloas`, 4 operators). The SSV node's complete ePBS PTC lifecycle was observed live across the epoch-2 fork: - **Dormant (epoch 0–1):** `DutyScheduler` starts `PTC_ATTESTER` + `PROPOSER_PREFERENCES`; they react to validator-index changes ("re-fetching PTC duties on next tick") but execute nothing. Boole active (`/ssv//boole/*` subnets). -- **Transition (epoch 2 / slot 64):** all 4 nodes' `PTC_ATTESTER` activates → `POST /eth/v1/validator/duties/ptc/2`. The first call at the exact fork-boundary slot returns CL `500 BeaconStateError(IncorrectStateVariant)` (lighthouse devnet-5: state not yet in the Gloas variant); the node's per-slot re-fetch **retries the next slot and succeeds**. Relevant to Track 1 too: expect a one-slot 500 at a node's first Gloas slot — the existing refetch absorbs it, no code change needed. +- **Transition (epoch 2 / slot 64):** all 4 nodes' `PTC_ATTESTER` activates → `POST /eth/v1/validator/duties/ptc/2`. The first call at the exact fork-boundary slot returns CL `500 BeaconStateError(IncorrectStateVariant)` (lighthouse devnet-5: state not yet in the Gloas variant); the node's per-slot re-fetch **retries the next slot and succeeds**. Relevant to the `devnet` initiative too: expect a one-slot 500 at a node's first Gloas slot — the existing refetch absorbs it, no code change needed. - **Executing (epoch 5 / slot 166):** with a validator held continuously active, `🔧 executing validator duty PTC_ATTESTER-e5-s166-v64` → `GET payload_attestation_data/166` → **`✔️ successfully submitted payload attestation` on all 4 operators**. An earlier duty at slot 148 correctly **failed-safe** — CL `404 No block received` on a missed slot (~80% block production on the devnet), so a validator's one-duty-per-epoch lands within an epoch or two; this is expected, not a node bug. **Two corrections to the prior handoff:** @@ -405,7 +441,7 @@ Rationale: ePBS removes the *out-of-protocol* apparatus #2855's doc configures ( ### ProposerDelay → ePBS split — DONE (#2901) Fork-gated per-slot (`IsGloasAtSlot`): - **Pre-ePBS:** `ProposerDelay` + `AllowDangerousProposerDelay` unchanged, but apply **pre-fork only** (today `ProposerDelay` fires under Gloas too — it sits before the `IsGloasAtSlot` branch in the proposer runner, so this is a real carve-out). -- **Post-ePBS:** `ProposerDelay`/`AllowDangerousProposerDelay` have no effect; a new **`ProposerDelayEPBS`** takes over with similar behavior, **hard-capped at 1000ms** (startup-rejected above it — no `AllowDangerous` override, for simplicity), **default 0** (opt-in). Tighter ~25% deadline + smaller/uncertain MEV upside ⇒ no aggressive escape hatch; default-off until devnet-5 measurements justify a value. +- **Post-ePBS:** `ProposerDelay`/`AllowDangerousProposerDelay` have no effect; a new **`ProposerDelayEPBS`** takes over with similar behavior, **hard-capped at 1000ms** (startup-rejected above it — no `AllowDangerous` override, for simplicity), **default 0** (opt-in). Tighter ~25% deadline + smaller/uncertain MEV upside ⇒ no aggressive escape hatch; default-off until live-devnet measurements justify a value. - Self-document the flag in #2901 (config-struct comment + `config.example.yaml`); the MEV_CONSIDERATIONS.md prose update lands later in #2855 once its shape is final. - Proposer budget under Gloas = `ProposerDelayEPBS` + the QBFT round timer (audit ① below). NOTE (corrected): the pre-Gloas `proposalSoftTimeout` does **not** apply under Gloas — the produce path uses `firstClientResult` and bypasses it (audit ④), so there's no proposalSoftTimeout↔delay coupling to tune. @@ -430,18 +466,18 @@ Every hardcoded slot-relative timeout/deadline classified so none is missed when ## §8 — Fork-transition monitoring + logs-first observability audit ### Observability principle — logs-first (DEBUG-complete) -**Every ePBS behavior we care about MUST be verifiable from DEBUG logs alone.** OTel metrics are *nice-to-have* — dashboards/aggregation only, never the sole evidence a behavior happened (there are no in-repo dashboards anyway, and Track 2 was verified purely from logs). Rule: any metric that records an ePBS decision/outcome must have a matching log (DEBUG or higher) carrying the same fact. The audit below closes the cases where this doesn't yet hold. +**Every ePBS behavior we care about MUST be verifiable from DEBUG logs alone.** OTel metrics are *nice-to-have* — dashboards/aggregation only, never the sole evidence a behavior happened (there are no in-repo dashboards anyway, and `local_testnet` was verified purely from logs). Rule: any metric that records an ePBS decision/outcome must have a matching log (DEBUG or higher) carrying the same fact. The audit below closes the cases where this doesn't yet hold. -### Fork-transition monitoring — proven on `local_testnet_gloas`; re-apply on devnet-5 / Hoodi / Sepolia -The dormant→transition→executing flow is already PROVEN on the local Gloas net (Track 2 RESULT above, epoch-2 fork, verified from logs). This is the generalized watch layer for any fork. +### Fork-transition monitoring — proven on `local_testnet_gloas`; re-apply on devnet-6 / Hoodi / Sepolia +The dormant→transition→executing flow is already PROVEN on the local Gloas net (the `local_testnet` RESULT above, epoch-2 fork, verified from logs). This is the generalized watch layer for any fork. **#1 blindspot:** the Gloas fork epoch is **not** in SSV config — it is read from each BN's `/eth/v1/config/spec` (`GLOAS_FORK_EPOCH`, `beacon/goclient/spec.go:259`), **with no startup log**. If a BN doesn't schedule it / BNs disagree, the node silently stays pre-Gloas (`IsGloas=false`, `IntervalDuration` stays /3) — no error, nothing ePBS fires. → pre-flight #1 + audit **G1**. -**Control/scale per network:** `local_testnet_gloas` (Track 2, DONE) sets the fork via `params-gloas.yaml` `gloas_fork_epoch: 2` — fully controllable. `glamsterdam-devnet-5` (Track 1) — epoch from the BN, real 512-member PTC. Hoodi/Sepolia — unscheduled today (`FarFutureEpoch`); monitor-only once they schedule Gloas (same watch, no timing control). +**Control/scale per network:** `local_testnet_gloas` (the `local_testnet` initiative, DONE) sets the fork via `params-gloas.yaml` `gloas_fork_epoch: 2` — fully controllable. `glamsterdam-devnet-6` (the `devnet` initiative) — epoch from the BN, real 512-member PTC. Hoodi/Sepolia — unscheduled today (`FarFutureEpoch`); monitor-only once they schedule Gloas (same watch, no timing control). **Pre-flight (T-minus a few epochs):** (1) every BN's `GLOAS_FORK_EPOCH` equal + not far-future [the node can't self-check this — G1]; (2) every BN serves the 8 Gloas routes (block produce/publish, envelope get/publish, PTC duties/data/submit, proposer-dependent-root); (3) `ProposerDelayEPBS` ≤ 1s (else boot-abort); (4) validator set actually hits proposer + PTC selections in-window; (5) baseline pre-fork (`/3`, zero ePBS roles) for a clean delta. -**Boundary quirks (from Track 2 — expect on any fork):** the first Gloas slot may return CL `500 BeaconStateError(IncorrectStateVariant)` → the per-slot refetch absorbs it (no code change); a missed proposal slot → CL `404 No block` → PTC fails-safe, the one-duty-per-epoch lands within an epoch or two. +**Boundary quirks (from `local_testnet` — expect on any fork):** the first Gloas slot may return CL `500 BeaconStateError(IncorrectStateVariant)` → the per-slot refetch absorbs it (no code change); a missed proposal slot → CL `404 No block` → PTC fails-safe, the one-duty-per-epoch lands within an epoch or two. **Per-section primary watch** (log = source of truth; metric in parens): - **§1 timing** — attestation submit rate holds across `/3→/4`; red flag: `⚠️ late duty execution` bursts (PTC lateness measured from the 75% cutoff). (`ssv.cl.request.duration{route=AttestationData}`, attestation refetch counters) @@ -453,7 +489,7 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) ### Log-coverage audit — G1–G4 + sweep DONE; G5 deferred -**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-5. Sweep DONE: §2 `built gloas attestation vote`+`payload_status_index`; §5 `built proposer preferences`+`dependent_root`/`fee_recipient`/`target_gas_limit`. +**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-6. Sweep DONE: §2 `built gloas attestation vote`+`payload_status_index`; §5 `built proposer preferences`+`dependent_root`/`fee_recipient`/`target_gas_limit`. **Goal:** make the logs-first principle hold across #2901 — every metric-recorded or decision-point ePBS behavior gets a DEBUG+ log; metrics unchanged (viz only). **Method:** per ePBS path (the new runners, duty handlers, goclient wrappers, value-checks, fork gates) enumerate behaviors/decisions/outcomes → confirm a DEBUG log carries each → where only a metric (or nothing) does, add a log. No behavior change; logs only. @@ -462,7 +498,7 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **G2 — build source (self vs external builder).** Metric `ssv.runner.proposal.build_source` only; the `🧊` log lacks it. → DEBUG on each Gloas submit (the `selfBuild(block)` bit already at `proposer.go:~541`): "self-built block" vs "external builder N". *The key ePBS proposer signal.* - **G3 — generic duty outcome succeeded/not_required.** `watchDutyOutcome.report` (`runner.go:~339`) records the metric for all four outcomes but logs only `failed`/`stuck` (Warn). → DEBUG "duty concluded" (outcome+role) for the non-warned outcomes → fully mirrors `ssv.runner.duty.outcome`. - **G4 — envelope produce/cache.** `produceBlindedEnvelope` (`envelope.go:277`) fetches+caches the heavy envelope unlogged. → DEBUG "building execution payload envelope" (slot, block root, Took). -- **G5 — PTC non-convergence.** Surfaces only as the generic "likely stuck" (§7 obs note). → distinct DEBUG/marker once its frequency is gauged on devnet-5. +- **G5 — PTC non-convergence.** Surfaces only as the generic "likely stuck" (§7 obs note). → distinct DEBUG/marker once its frequency is gauged on devnet-6. **Sweep (done):** §2 chosen vote index (EMPTY=0/FULL=1) → `built gloas attestation vote`; proposer-preferences pinned values → `built proposer preferences`. Standing check: any other metric-only ePBS fact gets a matching log. **NOT gaps (already DEBUG):** BN requests (`CL request done` + `route_name`), duty fetch/emit, `🔧 executing validator duty`, failures (Warn), abstain/skip, reorg-refresh. diff --git a/networkconfig/glamsterdam-devnet.go b/networkconfig/glamsterdam-devnet.go index dae84c868d..f4b851439d 100644 --- a/networkconfig/glamsterdam-devnet.go +++ b/networkconfig/glamsterdam-devnet.go @@ -9,19 +9,22 @@ import ( ) // GlamsterdamDevnetSSV is the SSV config for running against an ethpandaops Glamsterdam (Gloas / -// ePBS) devnet — seeded for devnet-5 (chain 7095321190, genesis 1780577940). The beacon config +// ePBS) devnet — currently devnet-6 (chain 7052886157, genesis 1782386940 ≈ 2026-06-25; verified +// live 2026-06-30 at epoch ~1118, so GLOAS_FORK_EPOCH 30 is well in the past). The beacon config // (genesis, fork schedule incl. GLOAS_FORK_EPOCH) is read from the BN at runtime; only the // SSV-side values live here. // -// Devnets are ephemeral and the SSV contracts are deployed per-network, so the three values -// marked TODO must be filled after the contract deploy + validator registration, and re-checked -// whenever the devnet is reset (or replaced by devnet-6+). +// Devnets are ephemeral and the SSV contracts are deployed per-network, so the values still marked +// TODO (the SSV contract address + sync offset, and the operator bootnode ENRs) must be filled +// after the contract deploy + operator/validator registration, and the whole block re-checked +// whenever the devnet is reset or replaced (devnet-5 → devnet-6 already happened; probe +// https://glamsterdam-devnet-N.ethpandaops.io/ to find the live one). var GlamsterdamDevnetSSV = &SSV{ Name: "glamsterdam-devnet", DomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x00}, NextDomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x01}, - // TODO(e2e): SSV contract address + its deployment block, set after deploying on the devnet EL. + // TODO(e2e): SSV contract address + its deployment block, set after deploying on the devnet-6 EL. RegistryContractAddr: ethcommon.Address{}, RegistrySyncOffset: big.NewInt(0), @@ -29,8 +32,9 @@ var GlamsterdamDevnetSSV = &SSV{ // TODO(e2e): the 4 operators' bootnode ENRs (discovery seeds for the cluster). Bootnodes: nil, - // TODO(e2e): approximate devnet validator count; feeds gossip message-rate scoring only. - TotalEthereumValidators: 1000, + // Approximate active-validator count (feeds gossip message-rate scoring only); ≈ the verified + // active set on devnet-6 (3909) as of 2026-06-30 — refresh on devnet reset/replace. + TotalEthereumValidators: 3909, // Boole is the SSV protocol baseline ePBS builds on — active from genesis on the devnet. Forks: SSVForks{Boole: 0}, From 573062598b04123a9bf722868f1fd2df64b65e12 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 30 Jun 2026 15:42:29 +0300 Subject: [PATCH 077/150] =?UTF-8?q?gloas:=20wire=20=C2=A74/=C2=A75/=C2=A76?= =?UTF-8?q?=20to=20the=20merged=20beacon-APIs=20(#580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beacon-APIs#580 merged and the proposer_preferences validator endpoint is in master, so the three endpoints that were stubbed/abstract are now implemented against the real merged paths (still hand-rolled HTTP — go-eth2-client has no Gloas types yet). - §5 proposer preferences: SubmitProposerPreferences POSTs to /eth/v1/validator/proposer_preferences (JSON); drop the ErrProposerPreferencesPublishUnavailable sentinel and the runner's skip branch; add a goclient test. - §4 proposer block: produce v3 -> v4 with include_payload=false — a Gloas block is bid-only, so the response stays a bare BeaconBlock (no BlockContents). - §6 envelope: produce path -> plural with beacon_block_root as a path segment; publish the blinded body (new SignedBlindedExecutionPayloadEnvelope SSZ type) with Eth-Execution-Payload-Blinded: true, so the producing BN un-blinds the payload from its cache (no blob sourcing needed). - Tighten the affected goclient/type/interface comments; refresh the plan doc (decision log, §8 watch line, dated §7 record). Remaining (unchanged): the stateless SignedExecutionPayloadEnvelopeContents body for cross-BN failover (needs blob sourcing); the go-eth2-client typed Gloas dedup; devnet validation of the exact produce/publish wire. --- EPBS_IMPLEMENTATION_PLAN.md | 15 ++- beacon/goclient/gloas_envelope.go | 34 +++--- beacon/goclient/gloas_envelope_test.go | 13 +-- beacon/goclient/gloas_proposer.go | 25 +++-- beacon/goclient/gloas_proposer_test.go | 10 +- beacon/goclient/proposer_preferences.go | 31 ++++-- beacon/goclient/proposer_preferences_test.go | 51 +++++++++ protocol/v2/blockchain/beacon/client.go | 13 ++- .../v2/ssv/runner/proposer_preferences.go | 9 -- .../types/gloas/execution_payload_envelope.go | 26 ++++- .../execution_payload_envelope_encoding.go | 101 +++++++++++++++++- .../gloas/execution_payload_envelope_test.go | 37 +++++++ .../v2/types/gloas/proposer_preferences.go | 7 -- 13 files changed, 301 insertions(+), 71 deletions(-) create mode 100644 beacon/goclient/proposer_preferences_test.go diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 86f51570d8..b4c9a70616 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ > **⚠️ ACTION — delete this file before [#2901](https://github.com/ssvlabs/ssv/pull/2901) is marked ready for review.** It is committed (rather than kept local) only as a temporary shared home for in-flight ePBS context. Before flipping #2901 to *ready for review*, move **all** still-useful / unfinished action items (e.g. the devnet e2e steps, the devnet-verify items, the upstream-gated follow-ups, the MEV_CONSIDERATIONS.md rewrite, the ProposerPreferences publish-finality follow-up) into the PR description, then remove this file in the same PR. Nothing here should outlive the PR. -**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged for the live devnet (now devnet-6); PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the `…Contents` blob-carrying publish body (deferred — devnet-gated) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. +**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged for the live devnet (now devnet-6); PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the stateless `…Contents` blob-carrying publish body (optional; §6 publishes the blinded/stateful body) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). **Update 2026-06-30: beacon-APIs#580 merged — §4 (v4 produce), §5 (proposer-preferences POST), §6 (blinded envelope publish) are now wired to the real merged endpoints (see the §7 update); go-eth2-client Gloas dedup still pending.** Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. **Baseline — the Boole fork (`boole-fork` is canonical).** ePBS builds on the SSV **Boole** protocol fork (successor to Alan): ssv-spec bumped `v1.2.2 → v1.2.3-pseudo`; `RoleAggregatorCommittee=6` with deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps; `SSVForks{ Boole }` + transition-window machinery; proposer round-robin; `lowestHash` topic→subnets. **Boole already shipped slices of this plan:** the node-side switches ePBS planned now exist — `protocol/v2/types/runner_role.go` (`RunnerRoleForValidatorDuty(duty, isBooleFork)`, fork-aware) and `protocol/v2/types/consensus_data.go` (version-switched extraction over `spectypes.ProposerConsensusData`). So **T7/T10 extend those, they don't author wrappers.** **`boole-fork` has not yet landed on stage** (stage is still `ssv-spec v1.2.2` / `SSVForks struct{}`; `boole-fork` is ~46 commits behind stage, tip Apr 2026) — but it's **expected to merge within ~2-3 weeks (≈ mid-July 2026), treated as ground truth** (see §2b). The build baseline is **`boole-fork`** (verify anchors against it). **Decision: ePBS starts now off `boole-fork`** — it must begin immediately for independent development + testing (against T2 mocks/devnet, which doesn't gate on Boole landing), so it can't wait for the merge; it rebases onto stage when Boole lands (~2-3 weeks) — see §6. The pre-Boole HEAD pin (`82a9f4f8f`) and §0/U findings are pre-Boole; corrections are inline where they flip (U0/U5/T7/T10/T11). @@ -301,8 +301,8 @@ The ssv-spec migration's handoff gates on **node-side-complete** (including T8's | U4 | Msg-validation model; ProposerPreferences carries `proposal_slot` (= `duty.Slot`), future-slot allowed via a role-specific `messageEarliness` exemption (T9) | node | T5, T6, T9 | **resolved** | | U5 | Gate Gloas by beacon epoch; slashing needs no change. Post-Boole: `SSVForks` now has `Boole` (pre-Boole "empty struct" basis corrected) — Gloas stays beacon-gated; re-pin `spec.go`/`beacon.go` anchors | node | T1, T4 | **resolved** | | U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters — nice-to-have viz; §8 logs are primary | node | T13 | **resolved** | -| — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | open (implement node-side vs #580; pin + watch; e2e on devnet) | -| — | `SignedProposerPreferences` publish endpoint | upstream | T5 | open (mock-only; no e2e until it exists) | +| — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | **resolved** (beacon-APIs#580 merged 2026-06-29; §4→v4 produce `include_payload=false`, §6→blinded publish wired; go-eth2-client dedup still pending) | +| — | `SignedProposerPreferences` publish endpoint | upstream | T5 | **resolved** (endpoint merged: `POST /eth/v1/validator/proposer_preferences`; §5 publishes for real, sentinel removed) | | — | `GLOAS_FORK_EPOCH` schedule | Ethereum | T11 | external (Glamsterdam ~Q3 2026; devnets now) | | — | Anchor wire-constant lock | node + Anchor | T1 | **PTC verified vs sigp/anchor `epbs` (matches); domains = consensus-specs = #632**; §5/§6 not in Anchor yet — re-check when added | | — | go-eth2-client upstream Gloas + fork rebase | upstream | T2 | optional **dedup** — we implement node-side now; swap for upstream `spec/gloas` when it ships | @@ -340,6 +340,13 @@ The ssv-spec migration's handoff gates on **node-side-complete** (including T8's PTC is implemented node-side end-to-end (wire types → goclient endpoints → ekm signing → `PTCAttesterRunner` → `SetupRunners` registration → scheduler handler with the 75% trigger → message validation; ssv-spec ePBS constants via PR ssvlabs/ssv-spec#632, go.mods pinned to its commit). This supersedes the T12 sketch with the concrete plan. +### Update 2026-06-30 — §4/§5/§6 wired to the merged beacon-APIs (#580) +[beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) merged 2026-06-29 and the `proposer_preferences` validator endpoint is in master, so the three endpoints that were abstract/stubbed are now implemented against the real merged paths. (go-eth2-client still has no Gloas types, so they stay hand-rolled HTTP — the typed dedup is unchanged and post-fork-OK. The older T5/T7/T8 notes below predate this and are superseded here.) +- **§5 proposer preferences** — `SubmitProposerPreferences` POSTs to `/eth/v1/validator/proposer_preferences` (JSON); the `ErrProposerPreferencesPublishUnavailable` sentinel + the runner skip-branch are removed; goclient test added. +- **§4 proposer block** — produce switched v3→**v4** (`/eth/v4/validator/blocks/{slot}?…&include_payload=false`): a Gloas block is bid-only, so the response stays a bare `BeaconBlock` (no `BlockContents`). +- **§6 envelope** — produce path → plural with `beacon_block_root` as a path segment; publish → plural, posting the **blinded** body (`SignedBlindedExecutionPayloadEnvelope`, new node-side SSZ type) with `Eth-Execution-Payload-Blinded: true` (stateful — the producing BN un-blinds from cache; no blob sourcing). +- **Remaining:** the stateless `SignedExecutionPayloadEnvelopeContents` body (full envelope + blobs/KZG, for cross-BN failover — needs blob sourcing); confirm the §6 body choice (blinded vs Contents) on a Gloas devnet; the go-eth2-client typed dedup. + **Committed on `epbs-gloas`** (rebased onto the refreshed `boole-fork` — see §6): the PTC implementation (above); two review rounds — first the `DataVersionGloas` → `networkconfig` / `BeaconForkAtEpoch` TODO / SSZ-regen tidy-up, then the 11-point PTC code review (unmasked-address requests, per-client timeouts, transient-BN warn, cutoff-baselined lateness, `signSSZRoot`, abstain semantics, handler tests); the `GlamsterdamDevnet` networkconfig stub; a `.dockerignore` `tla/` exclusion. **P1 image `ssvnode:epbs-gloas` builds + runs** (verified). ### Gate check — PASSED @@ -484,7 +491,7 @@ The dormant→transition→executing flow is already PROVEN on the local Gloas n - **§2 attestation** — value-check `rejecting/ignoring invalid message` with `error=` (`AttestationDataIndex>1`, GloasBeaconVote 120B-vs-112B decode). (committee `duty.outcome`) - **§3 PTC** — `fetched PTC duties` → `✔️ successfully submitted payload attestation`; `abstaining…no beacon block` occasional-ok / constant-bad; failures `failed to fetch PTC duties` / `PTC attestation failed…`. (`scheduler.executions{PTC_ATTESTER}`, `duty.outcome{PTC_ATTESTER}`) - **§4 proposer** — `🧊 got gloas beacon block proposal` → `✅ successfully submitted block proposal`; build-source self/external [**G2 — log gap**]. (`proposal.build_source`, `submissions.failed{proposer}`) -- **§5 prefs** — `emitted proposer preferences duties` → `proposer preferences reconstructed but publish endpoint unavailable; skipping submit` (**expected** — submit stubbed, marks `not_required`); red flag `proposer preferences failed: could not build`. (`request{route=ProposerDutiesDependentRoot}`) +- **§5 prefs** — `emitted proposer preferences duties` → `✔️ successfully submitted proposer preferences` (publish endpoint now live: `POST /eth/v1/validator/proposer_preferences`); red flag `could not submit proposer preferences` / `proposer preferences failed: could not build`. (`request{route=ProposerDutiesDependentRoot}`) - **§6 envelope** — builder: [**G4 — produce log gap**] → `✅ published execution payload envelope`; non-builder: `this operator did not build the decided envelope, skipping publication`. (`request{route=*ExecutionPayloadEnvelope}`) - **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index 656e9af8c3..1aec1cf8f6 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -11,12 +11,11 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// Gloas §6 envelope produce/publish endpoints (beacon-APIs#580, unmerged). Best-effort paths, as with -// the §4 block endpoints — verify against a real Gloas devnet BN. The publish body is the bare signed -// envelope (stateful path); the blob-carrying Contents body is deferred. +// Gloas §6 envelope produce/publish endpoints (beacon-APIs#580, merged 2026-06-29). Produce takes the +// beacon block root as a path segment; publish posts the blinded body (see SubmitExecutionPayloadEnvelope). const ( - gloasProduceEnvelopePath = "/eth/v1/validator/execution_payload_envelope/%d?beacon_block_root=%s" // slot, root 0x-hex - gloasPublishEnvelopePath = "/eth/v1/beacon/execution_payload_envelope" + gloasProduceEnvelopePath = "/eth/v1/validator/execution_payload_envelopes/%d/%s" // slot, beacon_block_root 0x-hex + gloasPublishEnvelopePath = "/eth/v1/beacon/execution_payload_envelopes" ) // GetExecutionPayloadEnvelope fetches the §6 execution-payload envelope (the payload the proposer @@ -27,13 +26,18 @@ func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0 }) } -// SubmitExecutionPayloadEnvelope publishes a signed §6 envelope as SSZ to all configured beacon -// nodes concurrently, succeeding if at least one accepts it. Re-publishing a signed envelope to -// multiple BNs is safe — they dedupe by block root. +// SubmitExecutionPayloadEnvelope publishes the signed §6 envelope as its blinded SSZ form to all +// configured beacon nodes concurrently, succeeding if at least one accepts it. The producing BN +// reconstructs the full payload from its cache. Re-publishing to multiple BNs is safe — they dedupe by +// block root. func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { - body, err := signed.MarshalSSZ() + blinded, err := signed.Blinded() if err != nil { - return fmt.Errorf("marshal signed execution payload envelope: %w", err) + return fmt.Errorf("blind execution payload envelope: %w", err) + } + body, err := blinded.MarshalSSZ() + if err != nil { + return fmt.Errorf("marshal signed blinded execution payload envelope: %w", err) } ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) @@ -47,7 +51,7 @@ func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed * // requestExecutionPayloadEnvelope GETs the produce endpoint and decodes the SSZ response into an envelope. func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { url := addr + fmt.Sprintf(gloasProduceEnvelopePath, slot, "0x"+hex.EncodeToString(beaconBlockRoot[:])) - body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil) + body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil, nil) if err != nil { return nil, err } @@ -58,8 +62,10 @@ func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phas return envelope, nil } -// submitExecutionPayloadEnvelope POSTs an SSZ-marshaled signed envelope to the publish endpoint. -func submitExecutionPayloadEnvelope(ctx context.Context, addr string, envelopeSSZ []byte) error { - _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ) +// submitExecutionPayloadEnvelope POSTs the SSZ-marshaled signed blinded envelope to the publish endpoint, +// tagged Eth-Execution-Payload-Blinded. +func submitExecutionPayloadEnvelope(ctx context.Context, addr string, blindedEnvelopeSSZ []byte) error { + headers := map[string]string{"Eth-Execution-Payload-Blinded": "true"} + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, blindedEnvelopeSSZ, headers) return err } diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go index f07cc3c91b..f2ee82e3a2 100644 --- a/beacon/goclient/gloas_envelope_test.go +++ b/beacon/goclient/gloas_envelope_test.go @@ -31,10 +31,9 @@ func TestRequestExecutionPayloadEnvelope(t *testing.T) { envelopeSSZ, err := minimalExecutionPayloadEnvelope().MarshalSSZ() require.NoError(t, err) - var gotMethod, gotPath, gotRoot, gotAccept string + var gotMethod, gotPath, gotAccept string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path - gotRoot = r.URL.Query().Get("beacon_block_root") gotAccept = r.Header.Get("Accept") _, _ = w.Write(envelopeSSZ) })) @@ -43,19 +42,20 @@ func TestRequestExecutionPayloadEnvelope(t *testing.T) { got, err := requestExecutionPayloadEnvelope(context.Background(), srv.URL, 9, phase0.Root{0xab}) require.NoError(t, err) require.Equal(t, http.MethodGet, gotMethod) - require.Equal(t, "/eth/v1/validator/execution_payload_envelope/9", gotPath) - require.Equal(t, "0xab"+strings.Repeat("0", 62), gotRoot) // 32-byte root, 0x-hex + // plural collection; beacon_block_root is a path segment, not a query param. + require.Equal(t, "/eth/v1/validator/execution_payload_envelopes/9/0xab"+strings.Repeat("0", 62), gotPath) require.Equal(t, "application/octet-stream", gotAccept) require.Equal(t, gloas.BuilderIndexSelfBuild, got.BuilderIndex) } func TestSubmitExecutionPayloadEnvelope(t *testing.T) { - var gotMethod, gotPath, gotVersion, gotContentType string + var gotMethod, gotPath, gotVersion, gotContentType, gotBlinded string var gotBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path gotVersion = r.Header.Get("Eth-Consensus-Version") gotContentType = r.Header.Get("Content-Type") + gotBlinded = r.Header.Get("Eth-Execution-Payload-Blinded") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) @@ -64,8 +64,9 @@ func TestSubmitExecutionPayloadEnvelope(t *testing.T) { err := submitExecutionPayloadEnvelope(context.Background(), srv.URL, []byte{0x01, 0x02}) require.NoError(t, err) require.Equal(t, http.MethodPost, gotMethod) - require.Equal(t, "/eth/v1/beacon/execution_payload_envelope", gotPath) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes", gotPath) require.Equal(t, consensusVersionGloas, gotVersion) + require.Equal(t, "true", gotBlinded) // published as the blinded (stateful) body require.Equal(t, "application/octet-stream", gotContentType) require.Equal(t, []byte{0x01, 0x02}, gotBody) } diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 9e65bb0ddb..557c903e82 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -14,17 +14,17 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// Gloas produce/publish endpoints (beacon-APIs#580, unmerged). produceBlockV4 reuses the v3 produce -// path with a Gloas SSZ response; publish is the standard v2 blocks endpoint. The exact path/headers -// may still shift upstream — these are best-effort and must be verified against a real Gloas devnet BN. +// Gloas produce/publish endpoints (beacon-APIs#580, merged 2026-06-29). Produce is v4 with +// include_payload=false: a Gloas block carries only the execution-payload bid (the payload ships in the +// §6 envelope), so the response is a bare BeaconBlock — no BlockContents. Publish is the standard v2 +// blocks endpoint (version-tagged via Eth-Consensus-Version). const ( - gloasProduceBlockPath = "/eth/v3/validator/blocks/%d?randao_reveal=%s&graffiti=%s" // slot, randao 0x-hex, graffiti 0x-hex + gloasProduceBlockPath = "/eth/v4/validator/blocks/%d?randao_reveal=%s&graffiti=%s&include_payload=false" // slot, randao 0x-hex, graffiti 0x-hex gloasPublishBlockPath = "/eth/v2/beacon/blocks" ) -// GetGloasBeaconBlock produces a Gloas (ePBS) block via the produce endpoint as SSZ — go-eth2-client -// has no Gloas types. Only the bare block is handled; a payload-included BlockContents response -// (blobs/KZG) is deferred. +// GetGloasBeaconBlock produces a Gloas (ePBS) block via the v4 produce endpoint as SSZ — go-eth2-client +// has no Gloas types. The response is a bare BeaconBlock (see the include_payload=false path). func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { return firstClientResult(ctx, gc, "GetGloasBeaconBlock", http.MethodGet, func(ctx context.Context, addr string) (*gloas.BeaconBlock, error) { return requestGloasBeaconBlock(ctx, addr, slot, graffiti, randao) @@ -51,7 +51,7 @@ func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.Sig // requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(graffiti)) - body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil) + body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil, nil) if err != nil { return nil, err } @@ -64,14 +64,14 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, // submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { - _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ) + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ, nil) return err } // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns // the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas -// consensus version. -func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte) ([]byte, error) { +// consensus version. extraHeaders (e.g. Eth-Execution-Payload-Blinded for the §6 envelope) are applied last. +func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, extraHeaders map[string]string) ([]byte, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) @@ -85,6 +85,9 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte) req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Eth-Consensus-Version", consensusVersionGloas) } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } resp, err := ptcHTTPClient.Do(req) if err != nil { diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index addbf8ed19..226e97c2ad 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -36,11 +36,12 @@ func TestRequestGloasBeaconBlock(t *testing.T) { blockSSZ, err := minimalGloasBlock().MarshalSSZ() require.NoError(t, err) - var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept string + var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotIncludePayload string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path gotRandao = r.URL.Query().Get("randao_reveal") gotGraffiti = r.URL.Query().Get("graffiti") + gotIncludePayload = r.URL.Query().Get("include_payload") gotAccept = r.Header.Get("Accept") _, _ = w.Write(blockSSZ) })) @@ -49,8 +50,9 @@ func TestRequestGloasBeaconBlock(t *testing.T) { got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}) require.NoError(t, err) require.Equal(t, http.MethodGet, gotMethod) - require.Equal(t, "/eth/v3/validator/blocks/7", gotPath) - require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th + require.Equal(t, "/eth/v4/validator/blocks/7", gotPath) + require.Equal(t, "false", gotIncludePayload) // bare block; payload ships in the §6 envelope + require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th require.Equal(t, "0x02", gotGraffiti) require.Equal(t, "application/octet-stream", gotAccept) require.Equal(t, phase0.Slot(7), got.Slot) @@ -84,6 +86,6 @@ func TestGloasOctetStreamHTTP_Non2xxIsError(t *testing.T) { })) defer srv.Close() - _, err := gloasOctetStreamHTTP(context.Background(), http.MethodGet, srv.URL, nil) + _, err := gloasOctetStreamHTTP(context.Background(), http.MethodGet, srv.URL, nil, nil) require.ErrorContains(t, err, "status 400") } diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index d996fa9a06..f1b3755582 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -3,6 +3,7 @@ package goclient import ( "context" "encoding/hex" + "encoding/json" "fmt" "net/http" "strings" @@ -12,6 +13,10 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) +// proposerPreferencesPath is the SIP #94 §5 publish endpoint; go-eth2-client has no Gloas types, so +// SubmitProposerPreferences is a hand-rolled JSON POST. +const proposerPreferencesPath = "/eth/v1/validator/proposer_preferences" + // ProposerDutiesDependentRoot returns the dependent root from the v2 proposer-duties response for the // given epoch — the proposer-lookahead seed a preference is pinned to (SIP #94 §5; callers pass the // proposal slot's epoch). Gloas's v2 endpoint computes this root under the new proposer-lookahead @@ -51,10 +56,24 @@ func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase return root, err } -// SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5). -// beacon-APIs exposes no validator-facing publication endpoint yet (the BN shape is TBD), so this -// returns the gloas.ErrProposerPreferencesPublishUnavailable sentinel — which the runner treats as a -// benign no-op — rather than silently dropping them; swap in a real client once the endpoint lands. -func (*GoClient) SubmitProposerPreferences(_ context.Context, _ []*gloas.SignedProposerPreferences) error { - return gloas.ErrProposerPreferencesPublishUnavailable +// SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5) to every +// beacon client, succeeding if at least one accepts them; each BN verifies them and gossips on the +// proposer_preferences topic. +func (gc *GoClient) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + return gc.multiClientSubmit(ctx, "SubmitProposerPreferences", func(ctx context.Context, client Client) error { + return submitProposerPreferences(ctx, ptcHTTPClient, gc.clientAddresses[client], preferences) + }) +} + +// submitProposerPreferences POSTs the signed proposer preferences as a JSON array to the validator endpoint. +func submitProposerPreferences(ctx context.Context, httpClient *http.Client, addr string, preferences []*gloas.SignedProposerPreferences) error { + body, err := json.Marshal(preferences) + if err != nil { + return fmt.Errorf("marshal proposer preferences: %w", err) + } + headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + return ptcDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) } diff --git a/beacon/goclient/proposer_preferences_test.go b/beacon/goclient/proposer_preferences_test.go new file mode 100644 index 0000000000..3a3c68b1ef --- /dev/null +++ b/beacon/goclient/proposer_preferences_test.go @@ -0,0 +1,51 @@ +package goclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// GoClient must satisfy the proposer-preferences beacon-node surface. +var _ beacon.ProposerPreferencesCalls = (*GoClient)(nil) + +func TestSubmitProposerPreferences(t *testing.T) { + prefs := []*gloas.SignedProposerPreferences{{ + Message: &gloas.ProposerPreferences{ + DependentRoot: phase0.Root{0xaa}, + ProposalSlot: 9, + ValidatorIndex: 7, + FeeRecipient: bellatrix.ExecutionAddress{0xcc}, + TargetGasLimit: 36_000_000, + }, + Signature: phase0.BLSSignature{0xbb}, + }} + + var gotMethod, gotPath, gotVersion string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotVersion = r.Header.Get("Eth-Consensus-Version") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + require.NoError(t, submitProposerPreferences(context.Background(), srv.Client(), srv.URL, prefs)) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v1/validator/proposer_preferences", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + want, err := json.Marshal(prefs) + require.NoError(t, err) + require.JSONEq(t, string(want), string(gotBody)) +} diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 5a6a763e70..2d3d0edbdc 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -151,10 +151,8 @@ type PTCCalls interface { SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error } -// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences. -// Publication has no beacon-API endpoint upstream yet (SIP #94 §5), so SubmitProposerPreferences -// returns the gloas.ErrProposerPreferencesPublishUnavailable sentinel for now (see -// beacon/goclient/proposer_preferences.go). +// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences (SIP #94 §5). +// go-eth2-client has no Gloas types, so these are hand-rolled over HTTP. type ProposerPreferencesCalls interface { // ProposerDutiesDependentRoot returns the proposer-duties dependent root for the epoch — the // seed the proposer-lookahead is pinned to. go-eth2-client drops it, so it's fetched via raw HTTP. @@ -165,7 +163,7 @@ type ProposerPreferencesCalls interface { // GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks // (SIP #94 §4). go-eth2-client has no Gloas types, so these are hand-rolled over HTTP against the -// produceBlockV4 / publish endpoints (beacon-APIs#580, unmerged) — verify and iterate on a Gloas devnet. +// merged produce-block-v4 / publish endpoints (beacon-APIs#580). type GloasProposerCalls interface { // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships // separately in the §6 envelope, so the block carries only the execution-payload bid. @@ -175,8 +173,9 @@ type GloasProposerCalls interface { } // GloasEnvelopeCalls is the beacon-node surface for the §6 execution-payload envelope (SIP #94 §6): -// fetching the payload the proposer committed to (self-build) and publishing the signed envelope. Like -// the block calls, these are hand-rolled over HTTP (beacon-APIs#580, unmerged) — verify on a Gloas devnet. +// fetching the payload the proposer committed to (self-build) and publishing the signed envelope as its +// blinded form. Like the block calls, these are hand-rolled over HTTP against the merged beacon-APIs#580 +// endpoints. type GloasEnvelopeCalls interface { // GetExecutionPayloadEnvelope fetches the execution-payload envelope for the proposer's committed // block, to be blinded, agreed in §6 QBFT, and signed. diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index f3e95db5ff..c4b4309256 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -287,15 +287,6 @@ func (r *proposerPreferencesSlotRunner) ProcessPreConsensus(ctx context.Context, Signature: signature, } if err := r.beacon.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{signed}); err != nil { - if errors.Is(err, gloas.ErrProposerPreferencesPublishUnavailable) { - // We converged and reconstructed correctly; there is simply no upstream endpoint to publish - // to yet (SIP #94 §5). Record a benign no-op rather than a failure so the known-missing - // endpoint doesn't surface as operator-actionable errors. Returning nil leaves the deferred - // err nil, so markDutyFailed does not also fire. - logger.Debug("proposer preferences reconstructed but publish endpoint unavailable; skipping submit", fields.Slot(r.proposerPreferences.ProposalSlot)) - r.markDutyNotRequired() - return nil - } return fmt.Errorf("could not submit proposer preferences: %w", err) } diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index d10191f365..8b3396e745 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -10,7 +10,7 @@ import ( // Regenerate with `go generate ./...`. -path is the package dir (not just this file) so sszgen resolves // the sibling gloas BuilderIndex the envelope references; --objs limits output to the blinded envelope, // collected into its own _encoding.go. Includes track go-eth2-client via `go list -m`. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload --output ./execution_payload_envelope_encoding.go" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,SignedBlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload --output ./execution_payload_envelope_encoding.go" // BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 // envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by @@ -46,12 +46,23 @@ type ExecutionPayloadEnvelope struct { } // SignedExecutionPayloadEnvelope wraps the envelope with the builder's signature (under -// DOMAIN_BEACON_BUILDER) — the §6 publication body on the stateful path. +// DOMAIN_BEACON_BUILDER). The cluster reconstructs this full signed form; it is published as either the +// blinded body below (stateful: the producing BN un-blinds from cache) or, for stateless cross-BN +// failover, a SignedExecutionPayloadEnvelopeContents (full envelope + blobs/KZG — not yet wired). type SignedExecutionPayloadEnvelope struct { Message *ExecutionPayloadEnvelope Signature phase0.BLSSignature `ssz-size:"96"` } +// SignedBlindedExecutionPayloadEnvelope wraps the blinded envelope with the builder's signature — the §6 +// publication body on the blinded (stateful) path, where the producing beacon node reconstructs the full +// envelope from its cache. The signature is valid here because the blinded root equals the full envelope's +// (see BlindedExecutionPayloadEnvelope) — the same property the §6 duty relies on to sign the blinded form. +type SignedBlindedExecutionPayloadEnvelope struct { + Message *BlindedExecutionPayloadEnvelope + Signature phase0.BLSSignature `ssz-size:"96"` +} + // Blinded returns the blinded form of the envelope — the full Payload replaced by its hash-tree root. // The blinded envelope hashes to the same root as this one, so the §6 duty agrees on and signs the // blinded value while the signature stays valid for this full envelope. The non-Payload fields are @@ -69,3 +80,14 @@ func (e *ExecutionPayloadEnvelope) Blinded() (*BlindedExecutionPayloadEnvelope, ParentBeaconBlockRoot: e.ParentBeaconBlockRoot, }, nil } + +// Blinded returns the signed blinded form for §6 publication: the same signature carried onto the blinded +// envelope (valid for both, since their roots match). Shares the inner envelope's non-Payload fields, so +// the result must not outlive this one. +func (s *SignedExecutionPayloadEnvelope) Blinded() (*SignedBlindedExecutionPayloadEnvelope, error) { + blinded, err := s.Message.Blinded() + if err != nil { + return nil, err + } + return &SignedBlindedExecutionPayloadEnvelope{Message: blinded, Signature: s.Signature}, nil +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go index 4ddbd9d36a..26722be088 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: a8cf3a06e8b956e381e3d5ab478fc369f2b31391e922bbe837321e1c803550b8 +// Hash: 73846c48b7cde91ed52a80435c1ba2fb557db5b699b2588ca331a2d26c43493f // Version: 0.1.3 package gloas @@ -388,3 +388,102 @@ func (s *SignedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (er func (s *SignedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { return ssz.ProofTree(s) } + +// MarshalSSZ ssz marshals the SignedBlindedExecutionPayloadEnvelope object +func (s *SignedBlindedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedBlindedExecutionPayloadEnvelope object to a target array +func (s *SignedBlindedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(100) + + // Offset (0) 'Message' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + // Field (0) 'Message' + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedBlindedExecutionPayloadEnvelope object +func (s *SignedBlindedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 100 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Message' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 100 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[4:100]) + + // Field (0) 'Message' + { + buf = tail[o0:] + if s.Message == nil { + s.Message = new(BlindedExecutionPayloadEnvelope) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedBlindedExecutionPayloadEnvelope object +func (s *SignedBlindedExecutionPayloadEnvelope) SizeSSZ() (size int) { + size = 100 + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(BlindedExecutionPayloadEnvelope) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedBlindedExecutionPayloadEnvelope object +func (s *SignedBlindedExecutionPayloadEnvelope) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedBlindedExecutionPayloadEnvelope object with a hasher +func (s *SignedBlindedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedBlindedExecutionPayloadEnvelope object +func (s *SignedBlindedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go index a485fb2c72..9a8f376f6d 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_test.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -117,3 +117,40 @@ func TestExecutionPayloadEnvelopeBlindsToSameRoot(t *testing.T) { require.NoError(t, err) require.Equal(t, blindedRoot, fullRoot, "blinded envelope must hash to the same root as the full envelope") } + +// SignedExecutionPayloadEnvelope.Blinded carries the signature unchanged onto the blinded envelope (whose +// message root equals the full one's), and the resulting signed blinded envelope — the §6 publication body +// on the blinded path — round-trips through SSZ. +func TestSignedExecutionPayloadEnvelopeBlindedRoundTrip(t *testing.T) { + full := &SignedExecutionPayloadEnvelope{ + Message: &ExecutionPayloadEnvelope{ + Payload: sampleExecutionPayload(), + ExecutionRequests: &electra.ExecutionRequests{}, + BuilderIndex: BuilderIndexSelfBuild, + BeaconBlockRoot: phase0.Root{0x02}, + ParentBeaconBlockRoot: phase0.Root{0x03}, + }, + Signature: phase0.BLSSignature{0xab, 0xcd}, + } + + signedBlinded, err := full.Blinded() + require.NoError(t, err) + require.Equal(t, full.Signature, signedBlinded.Signature, "signature must be carried unchanged") + + // The blinded message hashes to the same root as the full envelope, so the carried signature is valid. + fullMsgRoot, err := full.Message.HashTreeRoot() + require.NoError(t, err) + blindedMsgRoot, err := signedBlinded.Message.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, fullMsgRoot, blindedMsgRoot) + + b, err := signedBlinded.MarshalSSZ() + require.NoError(t, err) + out := &SignedBlindedExecutionPayloadEnvelope{} + require.NoError(t, out.UnmarshalSSZ(b)) + r1, err := signedBlinded.HashTreeRoot() + require.NoError(t, err) + r2, err := out.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, r1, r2) +} diff --git a/protocol/v2/types/gloas/proposer_preferences.go b/protocol/v2/types/gloas/proposer_preferences.go index d3540faed8..31b50f0e1b 100644 --- a/protocol/v2/types/gloas/proposer_preferences.go +++ b/protocol/v2/types/gloas/proposer_preferences.go @@ -33,13 +33,6 @@ type SignedProposerPreferences struct { Signature phase0.BLSSignature `ssz-size:"96"` } -// ErrProposerPreferencesPublishUnavailable is returned by a beacon client's SubmitProposerPreferences -// while no upstream beacon-API endpoint to publish Gloas (ePBS) proposer preferences exists yet -// (SIP #94 §5). It is a sentinel the runner matches (errors.Is) to record a benign no-op instead of a -// duty failure, so the known-missing endpoint doesn't surface as operator-actionable errors; it goes -// away once a real publish client lands. -var ErrProposerPreferencesPublishUnavailable = errors.New("proposer preferences: no upstream beacon-API publication endpoint yet (SIP #94 §5)") - // proposerPreferencesJSON is the beacon-API JSON form: uint64 as a decimal string, root/address as // 0x-hex, per go-eth2-client conventions. type proposerPreferencesJSON struct { From e6ad5474be5d9a43ddae26632b8f25f30250b27d Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 30 Jun 2026 17:24:13 +0300 Subject: [PATCH 078/150] gloas: normalize hand-rolled beacon-node address (fixes scheme-less config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - beacon/goclient: prepend an http(s) scheme to the stored beacon address (mirrors go-eth2-client's parseAddress) so the hand-rolled Gloas / PTC / proposer-preferences requests don't fail on a scheme-less config (e.g. the default `host:port`). The typed eth2clienthttp client normalized this internally, but the hand-rolled path used the raw stored value. + test. (PR review finding #1) - operator/duties/proposer_preferences.go: correct the reEmitLookahead comment — re-emitting for an already-emitted (proposal-slot, signer) is rejected by the <=1-per-(slot,signer) pre-consensus dedup (gossip penalty + non- convergence), not "harmless"; the fix awaits the SIP-94 §5 coordination rule. (PR review finding #2) - plan: defer §5 proposer-preferences re-emission to SIP-94 (discussion opened) with an explicit DEFERRED block + a BLOCKED decision-log row; add a devnet aggregate payload-status-index confirmation to the devnet checklist. (PR review findings #2, #4) --- EPBS_IMPLEMENTATION_PLAN.md | 16 +++++++++++++++- beacon/goclient/goclient.go | 12 ++++++++++++ beacon/goclient/goclient_addr_test.go | 20 ++++++++++++++++++++ operator/duties/proposer_preferences.go | 12 +++++++++--- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 beacon/goclient/goclient_addr_test.go diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index b4c9a70616..f40a5774b1 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -303,6 +303,7 @@ The ssv-spec migration's handoff gates on **node-side-complete** (including T8's | U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters — nice-to-have viz; §8 logs are primary | node | T13 | **resolved** | | — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | **resolved** (beacon-APIs#580 merged 2026-06-29; §4→v4 produce `include_payload=false`, §6→blinded publish wired; go-eth2-client dedup still pending) | | — | `SignedProposerPreferences` publish endpoint | upstream | T5 | **resolved** (endpoint merged: `POST /eth/v1/validator/proposer_preferences`; §5 publishes for real, sentinel removed) | +| — | §5 proposer-preferences **re-emission** (reorg/`dependent_root` change) vs the ≤1-per-`(slot,signer)` pre-consensus dedup | node + SIP | T5 | **BLOCKED — pending SIP-94 §5** ([discussion](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3499380025)); current `reEmitLookahead` is broken (penalty + non-convergence); proposed fix + interim in the §7 DEFERRED block | | — | `GLOAS_FORK_EPOCH` schedule | Ethereum | T11 | external (Glamsterdam ~Q3 2026; devnets now) | | — | Anchor wire-constant lock | node + Anchor | T1 | **PTC verified vs sigp/anchor `epbs` (matches); domains = consensus-specs = #632**; §5/§6 not in Anchor yet — re-check when added | | — | go-eth2-client upstream Gloas + fork rebase | upstream | T2 | optional **dedup** — we implement node-side now; swap for upstream `spec/gloas` when it ships | @@ -342,11 +343,23 @@ PTC is implemented node-side end-to-end (wire types → goclient endpoints → e ### Update 2026-06-30 — §4/§5/§6 wired to the merged beacon-APIs (#580) [beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) merged 2026-06-29 and the `proposer_preferences` validator endpoint is in master, so the three endpoints that were abstract/stubbed are now implemented against the real merged paths. (go-eth2-client still has no Gloas types, so they stay hand-rolled HTTP — the typed dedup is unchanged and post-fork-OK. The older T5/T7/T8 notes below predate this and are superseded here.) -- **§5 proposer preferences** — `SubmitProposerPreferences` POSTs to `/eth/v1/validator/proposer_preferences` (JSON); the `ErrProposerPreferencesPublishUnavailable` sentinel + the runner skip-branch are removed; goclient test added. +- **§5 proposer preferences** — `SubmitProposerPreferences` POSTs to `/eth/v1/validator/proposer_preferences` (JSON); the `ErrProposerPreferencesPublishUnavailable` sentinel + the runner skip-branch are removed; goclient test added. ⚠️ The *publish* path is done, but the reorg/`dependent_root` **re-emission** is a separate open issue — see the **DEFERRED** block below. - **§4 proposer block** — produce switched v3→**v4** (`/eth/v4/validator/blocks/{slot}?…&include_payload=false`): a Gloas block is bid-only, so the response stays a bare `BeaconBlock` (no `BlockContents`). - **§6 envelope** — produce path → plural with `beacon_block_root` as a path segment; publish → plural, posting the **blinded** body (`SignedBlindedExecutionPayloadEnvelope`, new node-side SSZ type) with `Eth-Execution-Payload-Blinded: true` (stateful — the producing BN un-blinds from cache; no blob sourcing). - **Remaining:** the stateless `SignedExecutionPayloadEnvelopeContents` body (full envelope + blobs/KZG, for cross-BN failover — needs blob sourcing); confirm the §6 body choice (blinded vs Contents) on a Gloas devnet; the go-eth2-client typed dedup. +### ⚠️ DEFERRED (pending SIP-94 §5 decision) — proposer-preferences re-emission +**Do not implement until SIP-94 §5 specifies the coordination rule.** Discussion opened: . + +**Issue (PR review finding #2 + SIP-94 §5, lines ~200/202/329):** SIP-94 requires re-emitting a new `ProposerPreferences` when `dependent_root` changes for a proposal slot already in the lookahead. But SSV message-validation — and Anchor's `message_validator` (`MAX_MESSAGES_PER_ROUND = 1`) — enforce **≤1 pre-consensus partial sig per `(slot, signer)`**, content-agnostic. `ProposerPreferences` pins `duty.Slot` to the **fixed proposal slot** (unlike `ValidatorRegistration`, whose slot advances), so the re-emission is a duplicate `(slot, signer)` → rejected → (1) gossip penalty on the re-emitting operator, (2) the new-root preference can't reconstruct. So today's `reEmitLookahead` (clear-all → re-emit) is **broken**, not merely incomplete: §5 reorg/`dependent_root` refresh does not work. + +**To implement once the SIP decides** (proposed in the discussion above): +- **Validation:** dedup `ProposerPreferencesPartialSig` by `(slot, signer, signing_root)` — reject a repeat root (true duplicate), allow a *new* root up to a bound `N` (proposed `4`). Every other pre-consensus type keeps ≤1 (no regression). +- **Handler** (`operator/duties/proposer_preferences.go`): re-emit only when a proposal slot's `dependent_root` actually changes (track the emitted root per slot), so the bound is spent on genuine refreshes. +- Relaxes a **cross-client** invariant → must land in SIP-94 §5 and be matched by Anchor (no §5 there yet). + +**Interim (NOT applied; flagged):** if the penalty disrupts devnet testing before the SIP resolves, suppress no-op re-emits (option a) to stop the penalty — but that does **not** refresh on reorg (a deliberate SIP deviation), so only as a stopgap. + **Committed on `epbs-gloas`** (rebased onto the refreshed `boole-fork` — see §6): the PTC implementation (above); two review rounds — first the `DataVersionGloas` → `networkconfig` / `BeaconForkAtEpoch` TODO / SSZ-regen tidy-up, then the 11-point PTC code review (unmasked-address requests, per-client timeouts, transient-BN warn, cutoff-baselined lateness, `signSSZRoot`, abstain semantics, handler tests); the `GlamsterdamDevnet` networkconfig stub; a `.dockerignore` `tla/` exclusion. **P1 image `ssvnode:epbs-gloas` builds + runs** (verified). ### Gate check — PASSED @@ -389,6 +402,7 @@ Do both: mock-green ≠ local-green ≠ interop-green. - [ ] **5 · Fill remaining stub TODOs** — `RegistryContractAddr` + `RegistrySyncOffset` (from 3), `Bootnodes` (operator ENRs from 3). - [ ] **6 · Run 4 operators** — `Network: glamsterdam-devnet`, `BeaconNodeAddr`=devnet-6 CL, `ETH1Addr`=devnet-6 EL (WS). - [ ] **7 · Verify PTC** — grep all 4 operators: `Gloas (ePBS) fork scheduled` → `fetched PTC duties` → `✔️ successfully submitted payload attestation`; abstain only on missed slots; cross-check the BN `payload_attestations` pool. +- [ ] **8 · Confirm §2 aggregate payload-status index (review finding #4)** — `computeAttestationDataRoot` (`beacon/goclient/aggregator.go`) fetches the attestation data fresh from the aggregator's own BN and keeps that BN's payload-status index, not the QBFT-decided index the committee signed. Confirm the aggregate fetch matches the signed index across BNs (low risk — payload status should be settled by aggregation time — but a cross-BN mismatch would silently miss the aggregate). ### `devnet` — operator run config (env vars; own EL/CL, no config file) Config is `cleanenv`-based: a node started without `--config` reads purely from env (`ReadEnv`), and env overrides a file when one is passed — so the whole operator can be driven by env vars. Minimal set per operator (we run our own EL/CL): diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index d44f07d8cb..f5788fa166 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -340,7 +340,19 @@ func (gc *GoClient) initMultiClient(ctx context.Context) error { return nil } +// normalizeBeaconAddr ensures the configured beacon address carries an http(s) scheme, mirroring +// go-eth2-client's parseAddress. eth2clienthttp normalizes internally, but the hand-rolled Gloas/PTC +// requests concatenate this stored address into request URLs, so a scheme-less config (e.g. "host:port") +// would otherwise fail http.NewRequest. Basic-auth credentials and any path prefix are preserved. +func normalizeBeaconAddr(addr string) string { + if !strings.HasPrefix(addr, "http") { + addr = "http://" + addr + } + return strings.TrimSuffix(addr, "/") +} + func (gc *GoClient) addSingleClient(ctx context.Context, addr string) error { + addr = normalizeBeaconAddr(addr) httpClient, err := eth2clienthttp.New( ctx, // WithAddress supplies the address of the beacon node, in host:port format. diff --git a/beacon/goclient/goclient_addr_test.go b/beacon/goclient/goclient_addr_test.go new file mode 100644 index 0000000000..96bc1d8615 --- /dev/null +++ b/beacon/goclient/goclient_addr_test.go @@ -0,0 +1,20 @@ +package goclient + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBeaconAddr(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"141.95.98.197:32555", "http://141.95.98.197:32555"}, // scheme-less host:port (config.yaml default) + {"http://example.url:5052", "http://example.url:5052"}, // already http + {"https://beacon.glamsterdam-devnet-6.ethpandaops.io", "https://beacon.glamsterdam-devnet-6.ethpandaops.io"}, // already https + {"user:pass@host:5052", "http://user:pass@host:5052"}, // basic-auth preserved + {"ethereum-beacon.blockpi.network/rpc/v1/KEY", "http://ethereum-beacon.blockpi.network/rpc/v1/KEY"}, // path prefix preserved + {"http://host:5052/", "http://host:5052"}, // trailing slash trimmed + } { + require.Equal(t, tc.want, normalizeBeaconAddr(tc.in), "input %q", tc.in) + } +} diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index e532fb3c09..ea92a8091e 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -65,9 +65,15 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { // reEmitLookahead drops the emitted-epoch markers so the next tick re-fetches and re-emits the // lookahead's preferences — after a reorg (new dependent_root) or a validator-set change (new local -// validators that missed an already-processed epoch). Per SIP #94 §5 a changed dependent_root yields a -// distinct gossip tuple, not a replacement; re-emitting an unchanged tuple is harmless (gossip keeps -// only the first). +// validators that missed an already-processed epoch). New local validators land on distinct proposal +// slots and emit correctly. +// +// KNOWN ISSUE (pending the SIP-94 §5 coordination rule): re-emitting for an already-emitted +// (proposal-slot, signer) — e.g. a changed dependent_root after a reorg — is rejected by the +// ≤1-per-(slot,signer) pre-consensus dedup, so the refresh neither converges nor replaces the prior +// preference, and the re-emitting operator is gossip-penalized. The fix (a bounded distinct-root +// allowance for ProposerPreferences pre-consensus, plus re-emitting only on a real dependent_root +// change) waits on the agreed SIP-94 §5 rule, since it relaxes a cross-client validation invariant. func (h *ProposerPreferencesHandler) reEmitLookahead(reason string) { h.logger.Debug("🔀 re-emitting proposer preferences on next tick", zap.String("reason", reason)) clear(h.processed) From 8e0ce20392d9b62c6306a73e0021893762feb77e Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 30 Jun 2026 18:59:54 +0300 Subject: [PATCH 079/150] =?UTF-8?q?gloas:=20plan=20=E2=80=94=20local=5Ftes?= =?UTF-8?q?tnet=20ePBS=20testing=20merged=20to=20aetheria=20main=20(=C2=A7?= =?UTF-8?q?7=20refresh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7 local_testnet: flip status from gated-on-review/merge to merged 2026-06-30 (aetheria #123/#126/#127 + ssv-mini#34 + ethereum2-monitor#504); note #128 (kurtosis Loki) still open; link the aetheria#125 checklist; mark the old merge/enable-order list completed. --- EPBS_IMPLEMENTATION_PLAN.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index f40a5774b1..239bbcceeb 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -392,7 +392,7 @@ Do both: mock-green ≠ local-green ≠ interop-green. - Risks: devnet resets/instability; validator activation latency; SSV contract deploy on a non-standard chain; per-client beacon-API PTC completeness (Lodestar/Lighthouse confirmed — verify the specific BN combo used). ### Progress checklist -**`local_testnet` initiative:** ✅ **DONE** — implemented + PTC submission PROVEN 2026-06-28 (see RESULT below); gated only on PR review/merge (#34 / #504 / #123). +**`local_testnet` initiative:** ✅ **DONE** — automated Loki-based `(ptc)` suite **merged to aetheria `main` 2026-06-30** (#123 / #126 / #127 + ssv-mini#34 + ethereum2-monitor#504); #128 (kurtosis-native Loki) is the one open follow-up. Full checklist: [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125). **`devnet` initiative:** - [x] **1 · Pick & verify the live devnet** — devnet-6 verified live 2026-06-30 (see step 1): epoch ~1118 (Gloas active), PTC route 200, open BN/EL endpoints recorded, spec wire-values match the PR. @@ -420,10 +420,10 @@ Config is `cleanenv`-based: a node started without `--config` reads purely from Once the 4 nodes are up, harvest their ENRs into the stub `Bootnodes` (or pin a known `NETWORK_PRIVATE_KEY` per node) so the cluster discovers itself. -### Initiative `local_testnet` (Track 2) — ssv-mini / aetheria local Gloas net — **IMPLEMENTED (2026-06-27); e2e PTC submission PROVEN 2026-06-28 (see result below); gated on review/merge** +### Initiative `local_testnet` (Track 2) — ssv-mini / aetheria local Gloas net — **MERGED to aetheria `main` 2026-06-30; automated `(ptc)` suite live (see result below). Tracking: [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125)** **Correction:** the earlier claim that `ethpandaops/ethereum-package@6.1.0` "has no Gloas/Glamsterdam fork (only up to Fulu+BPO)" is **wrong**. 6.1.0's `network_params.yaml` ships `gloas_fork_epoch` (+ the §1 quarter-slot `*_due_bps_gloas` timings) and threads it through `input_parser → el_cl_genesis_generator → values.env.tmpl`; its own CI test `.github/tests/fulu-genesis.yaml` runs `fulu_fork_epoch: 0` + `gloas_fork_epoch: 2`. So a local Gloas net is configurable **today** — no upstream wait. The only real blocker was Gloas-capable client images, solved by the ethpandaops `glamsterdam-devnet-5` builds (all EL/CL clients tagged). -Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change): +Built across these PRs — **all merged 2026-06-30** (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change). The automated assertion layer (Loki `(ptc)` suite #127, dbtest fail-fast #126) and the one remaining piece (#128) are tracked in [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125): 1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images (Gloas-capable; the local net **pins these independently of whichever public devnet is live** — bump only if a later devnet build carries a client fix you need), genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (greppable SSV logs = the automatable signal; dora as a manual visual aid): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. 2. **ethereum2-monitor [#504](https://github.com/ssvlabs/ethereum2-monitor/pull/504)** (scoped in #503) — Gloas block decoding (go-eth2-client v0.28.x can't decode Gloas): a reactive raw-JSON fallback in `beacon.FetchBlock` — no SSZ, no shared types. Re-enables E2M attestation validation on a Gloas chain. 3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. **Plus an E2M-coordination fix (2026-06-28, committed on `epbs/local-testnet-gloas`):** when `monitor-api` is absent the orchestrator now also sets the per-flow `e2m=false` (not just leaving `E2MURL` at a stale default), so the executor *skips* E2M and the `(event)` flow passes (on-chain lifecycle only) instead of hard-failing and tearing down. @@ -433,7 +433,7 @@ Implemented across three PRs (the local Gloas net reuses `local_testnet`'s on-ch - **`local_testnet` (DONE — keep green):** implemented + PROVEN (below). Re-run on each branch tip / in CI as the fast regression signal; owns its own client images + validator set. - **`devnet` (infra, your hands — start in parallel):** devnet-6 probe + sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + verify PTC via the greppable logs. Does **not** depend on `local_testnet`; can start any time. -**`local_testnet` merge/enable order (don't lose the monitor re-enable — it's the one cross-repo coupling):** +**`local_testnet` merge/enable order — ✅ completed (all merged 2026-06-30); kept for reference:** 1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; verify ePBS via greppable SSV logs — dora as a visual aid). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. 2. **ethereum2-monitor #504** — merge; then rebuild the monitor image (ssv-mini `make prepare-monitor`, built from `../ethereum2-monitor`). 3. **ssv-mini follow-up** — once #504 is in the monitor image, flip `monitor.enabled: true` in `params-gloas.yaml`. This turns on E2M attestation validation on the Gloas chain. *(This is the easy-to-forget step — it's intentionally deferred out of #34 so #34 stays mergeable today.)* From 20894f8236e691fb53472ed4c42f2b1dda695ee8 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 10:15:20 +0300 Subject: [PATCH 080/150] gloas: retry duty fetch when no validators are eligible yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposer, attester and sync-committee duty-fetch handlers marked an epoch/period intent fulfilled even when no validators were eligible at fetch time, so the duties were never fetched once validators did become eligible (e.g. after a beacon-metadata sync that arrives without an accompanying indices-change event). On the Gloas devnet this surfaced as the proposer missing every block it was assigned. fetchAndProcessDuties now returns (fetched bool, err error); the caller marks the intent fulfilled only when a beacon fetch actually ran. "No eligible validators" returns fetched=false, leaving the intent pending so a later tick retries — the same model the PTC and proposer-preferences handlers already use. Also drop the now-redundant per-fetch bracket log lines, and add a temporary proposer diagnostic (ssvlabs/ssv#2901) that dumps the Validators()/ SelfValidators() view on no-eligible to confirm the root cause on devnet. Scheduler tests updated to assert the intent stays pending and that a late indices-change remains the sole re-fetch trigger. --- operator/duties/attester.go | 38 +++++++------- operator/duties/attester_test.go | 33 +++++++----- operator/duties/proposer.go | 71 ++++++++++++++++++++------ operator/duties/proposer_test.go | 33 +++++++----- operator/duties/sync_committee.go | 43 ++++++++-------- operator/duties/sync_committee_test.go | 12 +++-- 6 files changed, 144 insertions(+), 86 deletions(-) diff --git a/operator/duties/attester.go b/operator/duties/attester.go index 88079468b4..71bad0d900 100644 --- a/operator/duties/attester.go +++ b/operator/duties/attester.go @@ -312,17 +312,16 @@ func (h *AttesterHandler) prepareCurrentEpoch(ctx context.Context, logger *zap.L defer span.End() if fulfilled, ok := h.dutyFetchIntents[currentEpoch]; ok && !fulfilled { - logger.Debug("fetching duties for the current epoch") - - err := h.fetchAndProcessDuties(ctx, logger, currentEpoch, currentSlot) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentEpoch, currentSlot) if err != nil { logger.Error("fetching duties for the current epoch failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentEpoch] = true // the intent has been fulfilled - - logger.Debug("fetching duties for the current epoch succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentEpoch] = true + } } span.SetStatus(codes.Ok, "") @@ -340,23 +339,25 @@ func (h *AttesterHandler) prepareNextEpoch(ctx context.Context, logger *zap.Logg // Delaying the duty fetch until it's a "good time" allows us to do it when the beacon node should be less busy. if fulfilled, ok := h.dutyFetchIntents[currentEpoch+1]; ok && !fulfilled && h.shouldFetchNextEpoch(currentSlot) { - logger.Debug("fetching duties for the next epoch") - - err := h.fetchAndProcessDuties(ctx, logger, currentEpoch+1, currentSlot) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentEpoch+1, currentSlot) if err != nil { logger.Error("fetching duties for the next epoch failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentEpoch+1] = true // the intent has been fulfilled - - logger.Debug("fetching duties for the next epoch succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentEpoch+1] = true + } } span.SetStatus(codes.Ok, "") } -func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot) error { +// fetchAndProcessDuties fetches and stores the epoch's attester duties. It returns fetched=false (with a +// nil error) when no validators are eligible yet — a not-ready state (e.g. beacon metadata not synced) the +// caller must retry rather than treat as fulfilled; fetched=true means a beacon fetch actually ran. +func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot) (fetched bool, err error) { ctx, span := tracer.Start(ctx, observability.InstrumentName(observabilityNamespace, "attester.fetch_and_store"), trace.WithAttributes( @@ -383,13 +384,14 @@ func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap logger.Debug(eventMsg) span.AddEvent(eventMsg) span.SetStatus(codes.Ok, "") - return nil + // No eligible validators yet — not a fulfilled fetch; caller retries on a later tick. + return false, nil } span.AddEvent("fetching duties from beacon node", trace.WithAttributes(observability.ValidatorCountAttribute(len(eligibleIndices)))) duties, err := h.beaconNode.AttesterDuties(ctx, targetEpoch, eligibleIndices) if err != nil { - return traces.Errorf(span, "failed to fetch attester duties: %w", err) + return false, traces.Errorf(span, "failed to fetch attester duties: %w", err) } specDuties := make([]*spectypes.ValidatorDuty, 0, len(duties)) @@ -426,7 +428,7 @@ func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap // and avoids unnecessary log noise if h.exporterMode { span.SetStatus(codes.Ok, "") - return nil + return true, nil } // calculate subscriptions @@ -434,7 +436,7 @@ func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap if len(subscriptions) == 0 { span.AddEvent("no subscriptions available") span.SetStatus(codes.Ok, "") - return nil + return true, nil } span.AddEvent("submitting beacon committee subscriptions", trace.WithAttributes( @@ -456,7 +458,7 @@ func (h *AttesterHandler) fetchAndProcessDuties(ctx context.Context, logger *zap }() span.SetStatus(codes.Ok, "") - return nil + return true, nil } func (h *AttesterHandler) toSpecDuty(duty *eth2apiv1.AttesterDuty, role spectypes.BeaconRole) *spectypes.ValidatorDuty { diff --git a/operator/duties/attester_test.go b/operator/duties/attester_test.go index 3f42f17901..77832a88bc 100644 --- a/operator/duties/attester_test.go +++ b/operator/duties/attester_test.go @@ -1106,6 +1106,16 @@ func TestScheduler_Attester_Indices_Changed_Too_Late_In_Slot(t *testing.T) { dutiesMap = hashmap.New[phase0.Epoch, []*eth2apiv1.AttesterDuty]() waitForDuties = &SafeValue[bool]{} ) + // A duty exists from the start, so there's an eligible validator (attester shares are derived from the + // duties map) and the silent startup fetch fulfils the current-epoch intent. That settles the epoch + // before the indices change, isolating what we test: a late indices change is the only slot-1 re-fetch. + dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.AttesterDuty{ + { + PubKey: phase0.BLSPubKey{1, 2, 3}, + Slot: phase0.Slot(2), + ValidatorIndex: phase0.ValidatorIndex(1), + }, + }) // Duty executor expects deadline to be set on the parent context (see "parent-context has no deadline set"). // This deadline needs to be large enough to not prevent tests from executing their intended flow. ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) @@ -1113,19 +1123,12 @@ func TestScheduler_Attester_Indices_Changed_Too_Late_In_Slot(t *testing.T) { fetchDutiesCall, executeDutiesCall := setupAttesterDutiesMock(scheduler, dutiesMap, waitForDuties) require.NoError(t, scheduler.Start(ctx)) - // STEP 1: slot 0 has no duties and no action. + // STEP 1: the startup fetch already fulfilled the current-epoch intent, so slot 0 has no action. ticker.Send(phase0.Slot(0)) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) // STEP 2: arrange for indices change to arrive too late for slot 0 processing. waitForDuties.Set(true) - dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.AttesterDuty{ - { - PubKey: phase0.BLSPubKey{1, 2, 3}, - Slot: phase0.Slot(2), - ValidatorIndex: phase0.ValidatorIndex(1), - }, - }) go func() { time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} @@ -1134,7 +1137,7 @@ func TestScheduler_Attester_Indices_Changed_Too_Late_In_Slot(t *testing.T) { // No fetching should happen on slot 0 because the indices change arrived too late in the slot. waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) - // STEP 3: on slot 1 the deferred indices change is processed and duties are fetched. + // STEP 3: on slot 1 the deferred indices change is processed and duties are re-fetched. waitForSlotN(scheduler.netCfg.Beacon, phase0.Slot(1)) ticker.Send(phase0.Slot(1)) waitForDutiesFetch(t, fetchDutiesCall, timeout) @@ -1338,7 +1341,7 @@ func TestScheduler_Attester_Retry_Current_Epoch_Fetch_On_Next_Tick(t *testing.T) }) } -func TestScheduler_Attester_No_Eligible_Validators_Does_Not_Retry_Current_Epoch_Fetch(t *testing.T) { +func TestScheduler_Attester_No_Eligible_Validators_Leaves_Current_Epoch_Fetch_Pending(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( handler = NewAttesterHandler(dutystore.NewDuties[eth2apiv1.AttesterDuty](), false) @@ -1354,13 +1357,17 @@ func TestScheduler_Attester_No_Eligible_Validators_Does_Not_Retry_Current_Epoch_ waitForDuties.Set(true) require.NoError(t, scheduler.Start(ctx)) - // Startup fetch completes as a successful no-op because there are no eligible validators. - require.True(t, handler.dutyFetchIntents[phase0.Epoch(0)]) + // With no eligible validators, the startup fetch is a no-op that must NOT mark the intent fulfilled — + // otherwise the duty would never be fetched once a validator becomes eligible (e.g. after a metadata + // sync that lands without an accompanying indices-change event). The intent stays pending. + require.False(t, handler.dutyFetchIntents[phase0.Epoch(0)]) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) - // The next tick must not retry the current-epoch fetch. + // The next tick re-evaluates the pending intent. There are still no eligible validators, so it + // short-circuits before any fetch and the intent remains pending (ready to be retried later). ticker.Send(phase0.Slot(0)) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) + require.False(t, handler.dutyFetchIntents[phase0.Epoch(0)]) // Stop scheduler & wait for graceful exit. cancel() diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index 63fa7f23b7..d8a3d254a2 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -255,17 +255,16 @@ func (h *ProposerHandler) prepareCurrentEpoch(ctx context.Context, logger *zap.L defer span.End() if fulfilled, ok := h.dutyFetchIntents[currentEpoch]; ok && !fulfilled { - logger.Debug("fetching duties for the current epoch") - - err := h.fetchAndProcessDuties(ctx, logger, currentEpoch, currentSlot) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentEpoch, currentSlot) if err != nil { logger.Error("fetching duties for the current epoch failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentEpoch] = true // the intent has been fulfilled - - logger.Debug("fetching duties for the current epoch succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentEpoch] = true + } } span.SetStatus(codes.Ok, "") @@ -283,17 +282,16 @@ func (h *ProposerHandler) prepareNextEpoch(ctx context.Context, logger *zap.Logg // Delaying the duty fetch until it's a "good time" allows us to do it when the beacon node should be less busy. if fulfilled, ok := h.dutyFetchIntents[currentEpoch+1]; ok && !fulfilled && h.shouldFetchNextEpoch(currentSlot) { - logger.Debug("fetching duties for the next epoch") - - err := h.fetchAndProcessDuties(ctx, logger, currentEpoch+1, currentSlot) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentEpoch+1, currentSlot) if err != nil { logger.Error("fetching duties for the next epoch failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentEpoch+1] = true // the intent has been fulfilled - - logger.Debug("fetching duties for the next epoch succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentEpoch+1] = true + } } span.SetStatus(codes.Ok, "") @@ -338,7 +336,10 @@ func (h *ProposerHandler) processExecution(ctx context.Context, epoch phase0.Epo span.SetStatus(codes.Ok, "") } -func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot) error { +// fetchAndProcessDuties fetches and stores the epoch's proposer duties. It returns fetched=false (with a +// nil error) when no validators are eligible yet — a not-ready state (e.g. beacon metadata not synced) the +// caller must retry rather than treat as fulfilled; fetched=true means a beacon fetch actually ran. +func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot) (fetched bool, err error) { ctx, span := tracer.Start(ctx, observability.InstrumentName(observabilityNamespace, "proposer.fetch_and_store"), trace.WithAttributes( @@ -360,10 +361,11 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap } if len(allEligibleIndices) == 0 { const eventMsg = "no eligible validators for epoch" - logger.Debug(eventMsg) + h.logNoEligibleDiagnostic(logger, targetEpoch) // TEMP(ssvlabs/ssv#2901): remove after devnet confirmation span.AddEvent(eventMsg) span.SetStatus(codes.Ok, "") - return nil + // No eligible validators yet — not a fulfilled fetch; caller retries on a later tick. + return false, nil } selfEligibleIndices := map[phase0.ValidatorIndex]struct{}{} @@ -376,7 +378,7 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap span.AddEvent("fetching duties from beacon node", trace.WithAttributes(observability.ValidatorCountAttribute(len(allEligibleIndices)))) duties, err := h.beaconNode.ProposerDuties(ctx, targetEpoch, allEligibleIndices) if err != nil { - return traces.Errorf(span, "failed to fetch proposer duties: %w", err) + return false, traces.Errorf(span, "failed to fetch proposer duties: %w", err) } specDuties := make([]*spectypes.ValidatorDuty, 0, len(duties)) @@ -409,7 +411,42 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap ) span.SetStatus(codes.Ok, "") - return nil + return true, nil +} + +// logNoEligibleDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901) for the "every proposer slot missed +// on the Gloas devnet" investigation. On zero eligible validators it dumps Validators() vs SelfValidators() +// so a devnet run can distinguish metadata-not-synced-yet (shares present but IsAttesting=false; the retry +// fix recovers these) from a diverging/empty Validators() view (self_attesting>0 yet none eligible; a +// different root cause the retry would not fix). Remove once the root cause is confirmed on devnet. +func (h *ProposerHandler) logNoEligibleDiagnostic(logger *zap.Logger, targetEpoch phase0.Epoch) { + all := h.validatorProvider.Validators() + self := h.validatorProvider.SelfValidators() + + selfAttesting := 0 + for _, s := range self { + if s.IsAttesting(targetEpoch) { + selfAttesting++ + } + } + + const sampleCap = 16 + samples := make([]string, 0, min(len(all), sampleCap)) + for i, s := range all { + if i >= sampleCap { + break + } + samples = append(samples, fmt.Sprintf("idx=%d status=%s hasMeta=%t attesting=%t liquidated=%t", + s.ValidatorIndex, s.Status, s.HasBeaconMetadata(), s.IsAttesting(targetEpoch), s.Liquidated)) + } + + logger.Debug("🔬 no eligible validators for epoch (diagnostic)", + zap.Uint64("target_epoch", uint64(targetEpoch)), + zap.Int("validators_total", len(all)), + zap.Int("self_validators", len(self)), + zap.Int("self_attesting", selfAttesting), + zap.Strings("shares", samples), + ) } func (h *ProposerHandler) toSpecDuty(duty *eth2apiv1.ProposerDuty, role spectypes.BeaconRole) *spectypes.ValidatorDuty { diff --git a/operator/duties/proposer_test.go b/operator/duties/proposer_test.go index bde43cf991..b8598f4bcd 100644 --- a/operator/duties/proposer_test.go +++ b/operator/duties/proposer_test.go @@ -1046,6 +1046,16 @@ func TestScheduler_Proposer_Indices_Changed_Too_Late_In_Slot(t *testing.T) { dutiesMap = hashmap.New[phase0.Epoch, []*eth2apiv1.ProposerDuty]() waitForDuties = &SafeValue[bool]{} ) + // A duty exists from the start, so there's an eligible validator (proposer shares are derived from the + // duties map) and the silent startup fetch fulfils the current-epoch intent. That settles the epoch + // before the indices change, isolating what we test: a late indices change is the only slot-1 re-fetch. + dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.ProposerDuty{ + { + PubKey: phase0.BLSPubKey{1, 2, 3}, + Slot: phase0.Slot(2), + ValidatorIndex: phase0.ValidatorIndex(1), + }, + }) // Duty executor expects deadline to be set on the parent context (see "parent-context has no deadline set"). // This deadline needs to be large enough to not prevent tests from executing their intended flow. ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) @@ -1053,19 +1063,12 @@ func TestScheduler_Proposer_Indices_Changed_Too_Late_In_Slot(t *testing.T) { fetchDutiesCall, executeDutiesCall := setupProposerDutiesMock(scheduler, dutiesMap, waitForDuties) require.NoError(t, scheduler.Start(ctx)) - // STEP 1: slot 0 has no duties and no action. + // STEP 1: the startup fetch already fulfilled the current-epoch intent, so slot 0 has no action. ticker.Send(phase0.Slot(0)) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) // STEP 2: arrange for indices change to arrive too late for slot 0 processing. waitForDuties.Set(true) - dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.ProposerDuty{ - { - PubKey: phase0.BLSPubKey{1, 2, 3}, - Slot: phase0.Slot(2), - ValidatorIndex: phase0.ValidatorIndex(1), - }, - }) go func() { time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} @@ -1074,7 +1077,7 @@ func TestScheduler_Proposer_Indices_Changed_Too_Late_In_Slot(t *testing.T) { // No fetching should happen on slot 0 because the indices change arrived too late in the slot. waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) - // STEP 3: on slot 1 the deferred indices change is processed and duties are fetched. + // STEP 3: on slot 1 the deferred indices change is processed and duties are re-fetched. waitForSlotN(scheduler.netCfg.Beacon, phase0.Slot(1)) ticker.Send(phase0.Slot(1)) waitForDutiesFetch(t, fetchDutiesCall, timeout) @@ -1212,7 +1215,7 @@ func TestScheduler_Proposer_Reorg_Previous_Epoch_Transition(t *testing.T) { }) } -func TestScheduler_Proposer_No_Eligible_Validators_Does_Not_Retry_Current_Epoch_Fetch(t *testing.T) { +func TestScheduler_Proposer_No_Eligible_Validators_Leaves_Current_Epoch_Fetch_Pending(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( handler = NewProposerHandler(dutystore.NewDuties[eth2apiv1.ProposerDuty](), false) @@ -1228,13 +1231,17 @@ func TestScheduler_Proposer_No_Eligible_Validators_Does_Not_Retry_Current_Epoch_ waitForDuties.Set(true) require.NoError(t, scheduler.Start(ctx)) - // Startup fetch completes as a successful no-op because there are no eligible validators. - require.True(t, handler.dutyFetchIntents[phase0.Epoch(0)]) + // With no eligible validators, the startup fetch is a no-op that must NOT mark the intent fulfilled — + // otherwise the duty would never be fetched once a validator becomes eligible (e.g. after a metadata + // sync that lands without an accompanying indices-change event). The intent stays pending. + require.False(t, handler.dutyFetchIntents[phase0.Epoch(0)]) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) - // The next tick must not retry the current-epoch fetch. + // The next tick re-evaluates the pending intent. There are still no eligible validators, so it + // short-circuits before any fetch and the intent remains pending (ready to be retried later). ticker.Send(phase0.Slot(0)) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) + require.False(t, handler.dutyFetchIntents[phase0.Epoch(0)]) // Stop scheduler & wait for graceful exit. cancel() diff --git a/operator/duties/sync_committee.go b/operator/duties/sync_committee.go index 9d68c30f66..904314d076 100644 --- a/operator/duties/sync_committee.go +++ b/operator/duties/sync_committee.go @@ -281,17 +281,16 @@ func (h *SyncCommitteeHandler) prepareCurrentPeriod( defer span.End() if fulfilled, ok := h.dutyFetchIntents[currentPeriod]; ok && !fulfilled { - logger.Debug("fetching duties for the current period") - - err := h.fetchAndProcessDuties(ctx, logger, currentPeriod, currentEpoch, currentSlot, waitForInit) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentPeriod, currentEpoch, currentSlot, waitForInit) if err != nil { logger.Error("fetching duties for the current period failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentPeriod] = true - - logger.Debug("fetching duties for the current period succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentPeriod] = true + } } span.SetStatus(codes.Ok, "") @@ -316,17 +315,16 @@ func (h *SyncCommitteeHandler) prepareNextPeriod( // Delaying the duty fetch until it's a "good time" allows us to do it when the beacon node should be less busy. if fulfilled, ok := h.dutyFetchIntents[currentPeriod+1]; ok && !fulfilled && h.shouldFetchNextPeriod(currentSlot) { - logger.Debug("fetching duties for the next period") - - err := h.fetchAndProcessDuties(ctx, logger, currentPeriod+1, currentEpoch, currentSlot, waitForInit) + fetched, err := h.fetchAndProcessDuties(ctx, logger, currentPeriod+1, currentEpoch, currentSlot, waitForInit) if err != nil { logger.Error("fetching duties for the next period failed", zap.Error(err)) span.SetStatus(codes.Error, err.Error()) return } - h.dutyFetchIntents[currentPeriod+1] = true - - logger.Debug("fetching duties for the next period succeeded") + // Fulfil the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. + if fetched { + h.dutyFetchIntents[currentPeriod+1] = true + } } span.SetStatus(codes.Ok, "") @@ -371,9 +369,11 @@ func (h *SyncCommitteeHandler) processExecution(ctx context.Context, period uint span.SetStatus(codes.Ok, "") } -// fetchAndProcessDuties fetches & stores the sync committee duties for the given period (current or future). -// The passed epoch must be the current epoch; for a future period the target epoch is resolved to that -// period's first epoch. +// fetchAndProcessDuties fetches & stores the given period's sync-committee duties. The period may be current +// or future; the passed epoch must be the current epoch, and for a future period the target epoch resolves to +// that period's first epoch. It returns fetched=false (with a nil error) when no validators are eligible yet — +// a not-ready state (e.g. beacon metadata not synced) the caller must retry rather than treat as fulfilled; +// fetched=true means a beacon fetch actually ran. func (h *SyncCommitteeHandler) fetchAndProcessDuties( ctx context.Context, logger *zap.Logger, @@ -381,7 +381,7 @@ func (h *SyncCommitteeHandler) fetchAndProcessDuties( epoch phase0.Epoch, currentSlot phase0.Slot, waitForInit bool, -) error { +) (fetched bool, err error) { start := time.Now() ctx, span := tracer.Start(ctx, observability.InstrumentName(observabilityNamespace, "sync_committee.fetch_and_store"), @@ -412,13 +412,14 @@ func (h *SyncCommitteeHandler) fetchAndProcessDuties( logger.Debug(eventMsg) span.AddEvent(eventMsg) span.SetStatus(codes.Ok, "") - return nil + // No eligible validators yet — not a fulfilled fetch; caller retries on a later tick. + return false, nil } span.AddEvent("fetching duties from beacon node", trace.WithAttributes(observability.ValidatorCountAttribute(len(eligibleIndices)))) duties, err := h.beaconNode.SyncCommitteeDuties(ctx, epoch, eligibleIndices) if err != nil { - return traces.Errorf(span, "failed to fetch sync committee duties: %w", err) + return false, traces.Errorf(span, "failed to fetch sync committee duties: %w", err) } selfShares := h.validatorProvider.SelfParticipatingValidators(epoch) @@ -448,7 +449,7 @@ func (h *SyncCommitteeHandler) fetchAndProcessDuties( // and avoids unnecessary log noise if h.exporterMode { span.SetStatus(codes.Ok, "") - return nil + return true, nil } // lastEpoch + 1 because the subscription's "until" epoch is exclusive @@ -458,7 +459,7 @@ func (h *SyncCommitteeHandler) fetchAndProcessDuties( if len(subscriptions) == 0 { span.AddEvent("no subscriptions available") span.SetStatus(codes.Ok, "") - return nil + return true, nil } span.AddEvent("submitting beacon sync committee subscriptions", trace.WithAttributes( @@ -480,7 +481,7 @@ func (h *SyncCommitteeHandler) fetchAndProcessDuties( }() span.SetStatus(codes.Ok, "") - return nil + return true, nil } func (h *SyncCommitteeHandler) logDutiesFetched( diff --git a/operator/duties/sync_committee_test.go b/operator/duties/sync_committee_test.go index 0a0d456013..6493915cd8 100644 --- a/operator/duties/sync_committee_test.go +++ b/operator/duties/sync_committee_test.go @@ -893,7 +893,7 @@ func TestScheduler_SyncCommittee_Retry_Current_Period_Fetch_On_Next_Tick(t *test }) } -func TestScheduler_SyncCommittee_No_Eligible_Validators_Does_Not_Retry_Current_Period_Fetch(t *testing.T) { +func TestScheduler_SyncCommittee_No_Eligible_Validators_Leaves_Current_Period_Fetch_Pending(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( handler = NewSyncCommitteeHandler(dutystore.NewSyncCommitteeDuties(), false) @@ -909,13 +909,17 @@ func TestScheduler_SyncCommittee_No_Eligible_Validators_Does_Not_Retry_Current_P waitForDuties.Set(true) require.NoError(t, scheduler.Start(ctx)) - // Startup fetch completes as a successful no-op because there are no eligible validators. - require.True(t, handler.dutyFetchIntents[0]) + // With no eligible validators, the startup fetch is a no-op that must NOT mark the intent fulfilled — + // otherwise the duty would never be fetched once a validator becomes eligible (e.g. after a metadata + // sync that lands without an accompanying indices-change event). The intent stays pending. + require.False(t, handler.dutyFetchIntents[0]) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) - // The next tick must not retry the current-period fetch. + // The next tick re-evaluates the pending intent. There are still no eligible validators, so it + // short-circuits before any fetch and the intent remains pending (ready to be retried later). ticker.Send(phase0.Slot(0)) waitForNoAction(t, fetchDutiesCall, executeDutiesCall, noActionTimeout) + require.False(t, handler.dutyFetchIntents[0]) // Stop scheduler & wait for graceful exit. cancel() From f958c2dd11e2a614b009adf42850a397913eb432 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 11:09:54 +0300 Subject: [PATCH 081/150] fix linter --- operator/duties/attester.go | 4 ++-- operator/duties/attester_test.go | 2 +- operator/duties/proposer.go | 4 ++-- operator/duties/proposer_test.go | 2 +- operator/duties/sync_committee.go | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/operator/duties/attester.go b/operator/duties/attester.go index 71bad0d900..df82fd5d7a 100644 --- a/operator/duties/attester.go +++ b/operator/duties/attester.go @@ -318,7 +318,7 @@ func (h *AttesterHandler) prepareCurrentEpoch(ctx context.Context, logger *zap.L span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentEpoch] = true } @@ -345,7 +345,7 @@ func (h *AttesterHandler) prepareNextEpoch(ctx context.Context, logger *zap.Logg span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentEpoch+1] = true } diff --git a/operator/duties/attester_test.go b/operator/duties/attester_test.go index 77832a88bc..d5e0d8cf0f 100644 --- a/operator/duties/attester_test.go +++ b/operator/duties/attester_test.go @@ -1107,7 +1107,7 @@ func TestScheduler_Attester_Indices_Changed_Too_Late_In_Slot(t *testing.T) { waitForDuties = &SafeValue[bool]{} ) // A duty exists from the start, so there's an eligible validator (attester shares are derived from the - // duties map) and the silent startup fetch fulfils the current-epoch intent. That settles the epoch + // duties map) and the silent startup fetch fulfills the current-epoch intent. That settles the epoch // before the indices change, isolating what we test: a late indices change is the only slot-1 re-fetch. dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.AttesterDuty{ { diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index d8a3d254a2..53841c98c2 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -261,7 +261,7 @@ func (h *ProposerHandler) prepareCurrentEpoch(ctx context.Context, logger *zap.L span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentEpoch] = true } @@ -288,7 +288,7 @@ func (h *ProposerHandler) prepareNextEpoch(ctx context.Context, logger *zap.Logg span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible epoch stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentEpoch+1] = true } diff --git a/operator/duties/proposer_test.go b/operator/duties/proposer_test.go index b8598f4bcd..bcecdfe8a3 100644 --- a/operator/duties/proposer_test.go +++ b/operator/duties/proposer_test.go @@ -1047,7 +1047,7 @@ func TestScheduler_Proposer_Indices_Changed_Too_Late_In_Slot(t *testing.T) { waitForDuties = &SafeValue[bool]{} ) // A duty exists from the start, so there's an eligible validator (proposer shares are derived from the - // duties map) and the silent startup fetch fulfils the current-epoch intent. That settles the epoch + // duties map) and the silent startup fetch fulfills the current-epoch intent. That settles the epoch // before the indices change, isolating what we test: a late indices change is the only slot-1 re-fetch. dutiesMap.Set(phase0.Epoch(0), []*eth2apiv1.ProposerDuty{ { diff --git a/operator/duties/sync_committee.go b/operator/duties/sync_committee.go index 904314d076..c27a626b3e 100644 --- a/operator/duties/sync_committee.go +++ b/operator/duties/sync_committee.go @@ -287,7 +287,7 @@ func (h *SyncCommitteeHandler) prepareCurrentPeriod( span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentPeriod] = true } @@ -321,7 +321,7 @@ func (h *SyncCommitteeHandler) prepareNextPeriod( span.SetStatus(codes.Error, err.Error()) return } - // Fulfil the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. + // Fulfill the intent only if a fetch actually ran; a not-yet-eligible period stays pending so a later tick retries. if fetched { h.dutyFetchIntents[currentPeriod+1] = true } From b8f7b1615a6ae7e4c2635132ca1b43962d97cd48 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 12:06:13 +0300 Subject: [PATCH 082/150] gloas: bring ePBS submit paths onto the mature runners' idiom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps: an ePBS submit failure was only surfaced via the returned error, which the message pump logs at DEBUG ("could not handle message") — invisible at INFO; and §3/§5/§6 recorded no submission metrics at all. Align them with the mature runners (committee.go, aggregator.go): - Failure: recordFailedSubmission + a dedicated logger.Error(errMsg, fields.Slot(...), zap.Error(err)) + an "%s: %w" wrap reusing one const — at §6 envelope, §4 proposer (both submits), §3 PTC, §5 proposer-preferences. - Success: recordSuccessfulSubmission on the §6/§3/§5 success paths, so every role reports both outcomes (§4 already did via finishSubmittedProposal). PTC uses spectypes.BNRolePTCAttester, already a BeaconRole in the pinned ssv-spec, so both metrics work with no spec change. --- protocol/v2/ssv/runner/envelope.go | 6 +++++- protocol/v2/ssv/runner/proposer.go | 8 ++++++-- protocol/v2/ssv/runner/proposer_preferences.go | 6 +++++- protocol/v2/ssv/runner/ptc_attester.go | 6 +++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 9acbe973b3..fcded41f70 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -210,8 +210,12 @@ func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap. if r.builtDecidedEnvelope(cd.DataSSZ) { signed := &gloas.SignedExecutionPayloadEnvelope{Message: r.cachedEnvelope, Signature: sig} if err := r.GetBeaconNode().SubmitExecutionPayloadEnvelope(ctx, signed); err != nil { - return fmt.Errorf("submit execution payload envelope: %w", err) + recordFailedSubmission(ctx, spectypes.BNRoleEnvelopeBuilder) + const errMsg = "could not submit execution payload envelope" + logger.Error(errMsg, fields.Slot(cd.Duty.Slot), zap.Error(err)) + return fmt.Errorf("%s: %w", errMsg, err) } + recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(cd.Duty.Slot), spectypes.BNRoleEnvelopeBuilder) logger.Info("✅ published execution payload envelope", fields.Slot(cd.Duty.Slot)) } else { logger.Debug("this operator did not build the decided envelope, skipping publication", fields.Slot(cd.Duty.Slot)) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 3a37f86f90..6df989acbe 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -480,7 +480,9 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L start := time.Now() if err := r.GetBeaconNode().SubmitBeaconBlock(ctx, vBlk, specSig); err != nil { recordFailedSubmission(ctx, spectypes.BNRoleProposer) - return fmt.Errorf("submit beacon block: %w", err) + const errMsg = "could not submit beacon block" + logger.Error(errMsg, fields.Slot(validatorConsensusData.Duty.Slot), zap.Error(err)) + return fmt.Errorf("%s: %w", errMsg, err) } return r.finishSubmittedProposal(ctx, logger, span, start, proposalTraceAttrs) } @@ -540,7 +542,9 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { recordFailedSubmission(ctx, spectypes.BNRoleProposer) - finishErr = fmt.Errorf("submit gloas beacon block: %w", err) + const errMsg = "could not submit gloas beacon block" + logger.Error(errMsg, fields.Slot(cd.Duty.Slot), zap.Error(err)) + finishErr = fmt.Errorf("%s: %w", errMsg, err) } else { recordProposalBuildSource(ctx, selfBuild(block)) finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index c4b4309256..636153c9d4 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -287,9 +287,13 @@ func (r *proposerPreferencesSlotRunner) ProcessPreConsensus(ctx context.Context, Signature: signature, } if err := r.beacon.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{signed}); err != nil { - return fmt.Errorf("could not submit proposer preferences: %w", err) + recordFailedSubmission(ctx, spectypes.BNRoleProposerPreferences) + const errMsg = "could not submit proposer preferences" + logger.Error(errMsg, fields.Slot(r.proposerPreferences.ProposalSlot), zap.Error(err)) + return fmt.Errorf("%s: %w", errMsg, err) } + recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(r.proposerPreferences.ProposalSlot), spectypes.BNRoleProposerPreferences) r.markDutySucceeded() logger.Info("✔️ successfully submitted proposer preferences", fields.Slot(r.proposerPreferences.ProposalSlot)) return nil diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index c209d5ab7a..79a889ca3e 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -119,9 +119,13 @@ func (r *PTCAttesterRunner) ProcessPreConsensus(ctx context.Context, logger *zap Signature: signature, } if err := r.beacon.SubmitPayloadAttestationMessages(ctx, []*gloas.PayloadAttestationMessage{msg}); err != nil { - return fmt.Errorf("could not submit payload attestation message: %w", err) + recordFailedSubmission(ctx, spectypes.BNRolePTCAttester) + const errMsg = "could not submit payload attestation message" + logger.Error(errMsg, fields.Slot(r.payloadAttestationData.Slot), zap.Error(err)) + return fmt.Errorf("%s: %w", errMsg, err) } + recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(r.payloadAttestationData.Slot), spectypes.BNRolePTCAttester) r.markDutySucceeded() logger.Info("✔️ successfully submitted payload attestation", fields.Slot(r.payloadAttestationData.Slot)) return nil From b99cc5655bcb847f972c60452027baa440242448 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 13:16:09 +0300 Subject: [PATCH 083/150] gloas: tighten ePBS doc-comments - ptc_attester abstain: drop the markDutyNotRequired/metric plumbing chain, keep the BN-contract fact and the zero-root caveat - value_check gloas index: phrase the epoch-only-SP note positively (kept in sync, future-proof) instead of "inert to the comparison" - execution_payload_bid: drop the stale go-eth2-client #269 aside - payload_attestation: note DomainPTCAttester domain epoch = epoch(Slot), matching the ProposerPreferences type doc --- protocol/v2/ssv/runner/ptc_attester.go | 9 +++------ protocol/v2/ssv/value_check.go | 6 +++--- protocol/v2/types/gloas/execution_payload_bid.go | 7 +++---- protocol/v2/types/gloas/payload_attestation.go | 2 +- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index 79a889ca3e..7f025b7bb9 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -167,12 +167,9 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, r.markDutyFailed(err) return nil } - // BN contract: an all-zero BeaconBlockRoot is the beacon node signaling "no block for this slot" - // (the SIP #94 §3 abstain trigger) — we sign and submit nothing. The abstention is still counted: - // markDutyNotRequired concludes the duty as dutyOutcomeNotRequired, which watchDutyOutcome records - // on the ssv.runner.duty.outcome metric (labeled by role), so PTC abstentions are observable there. - // Caveat: a BN that erroneously returns a zero root would be silently misclassified as a benign - // abstention rather than a fault — we cannot distinguish the two from the root alone. + // BN contract: an all-zero BeaconBlockRoot signals "no block for this slot" — the SIP #94 §3 abstain + // trigger. We sign and submit nothing; markDutyNotRequired still records the abstention for metrics. + // Caveat: a BN erroneously returning a zero root is indistinguishable from a genuine abstention here. if data.BeaconBlockRoot == (phase0.Root{}) { logger.Debug("abstaining from PTC attestation: no beacon block for slot", fields.Slot(slot)) r.markDutyNotRequired() diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index 86c15cdda3..bbb51d675a 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -124,9 +124,9 @@ func (v *gloasVoteChecker) CheckValue(value []byte) error { attestationData := &phase0.AttestationData{ Slot: v.slot, - // The decided payload-status index — exactly what constructAttestationData will sign — so the - // slashing pre-check sees the same data that gets signed. SSV's protection is epoch-only, so the - // index is inert to the comparison either way. + // The decided payload-status index — the same value constructAttestationData will sign — so the + // slashing pre-check sees exactly the signed data. (SSV slashing protection is epoch-only, so the + // index doesn't change today's outcome, but keeping the two in sync is correct and future-proof.) Index: bv.AttestationDataIndex, BeaconBlockRoot: bv.BlockRoot, Source: bv.Source, diff --git a/protocol/v2/types/gloas/execution_payload_bid.go b/protocol/v2/types/gloas/execution_payload_bid.go index aa8b120241..705add70ef 100644 --- a/protocol/v2/types/gloas/execution_payload_bid.go +++ b/protocol/v2/types/gloas/execution_payload_bid.go @@ -18,10 +18,9 @@ type BuilderIndex uint64 const BuilderIndexSelfBuild = BuilderIndex(^uint64(0)) // ExecutionPayloadBid is the Gloas (ePBS) bid the proposer commits to in the block body, replacing the -// inline execution payload of pre-Gloas blocks: the block carries only this -// commitment, and the payload itself ships separately in the envelope (§6). For self-build, BuilderIndex -// is BuilderIndexSelfBuild. Fields match the pinned spec / go-eth2-client PR #280 (the earlier #269 -// shape — a single BlobKZGCommitmentsRoot — predates the blob-commitments-list change and is stale). +// inline execution payload of pre-Gloas blocks: the block carries only this commitment, and the payload +// itself ships separately in the envelope (§6). For self-build, BuilderIndex is BuilderIndexSelfBuild. +// Fields match the pinned spec / go-eth2-client PR #280. type ExecutionPayloadBid struct { ParentBlockHash phase0.Hash32 `ssz-size:"32"` ParentBlockRoot phase0.Root `ssz-size:"32"` diff --git a/protocol/v2/types/gloas/payload_attestation.go b/protocol/v2/types/gloas/payload_attestation.go index 8af5745edf..33a8d1aaa9 100644 --- a/protocol/v2/types/gloas/payload_attestation.go +++ b/protocol/v2/types/gloas/payload_attestation.go @@ -15,7 +15,7 @@ import ( // PayloadAttestationData is the Gloas (ePBS) datum a PTC member attests to: whether the // execution payload for BeaconBlockRoot at Slot was present, and its blobs available. -// Signed under DomainPTCAttester. Fixed 42-byte SSZ. +// Signed under DomainPTCAttester with domain epoch = epoch(Slot). Fixed 42-byte SSZ. type PayloadAttestationData struct { BeaconBlockRoot phase0.Root `ssz-size:"32"` Slot phase0.Slot From 70862f161474754350b1f708f11b580a2f9d61ce Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 13:47:33 +0300 Subject: [PATCH 084/150] =?UTF-8?q?gloas:=20=C2=A75=20publish-finality=20K?= =?UTF-8?q?NOWN=20ISSUE=20+=20consolidated=20plan=20TODO=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: KNOWN ISSUE comment in buildProposerPreferences for the SIP-94 §5 publish-finality gap (dependent_root/fee_recipient/target_gas_limit read at emit time and published on quorum, with no hold-until-final guard) — parity with the reEmitLookahead KNOWN ISSUE. Plan: record the §2/§4/§5/§6 review sweep (findings #1–#4; none are functional bugs) and add an explicit "§1b — Revisit-later TODOs" consolidated index so the deferred items live in one place, not sprinkled across the plan. Corrects the earlier "cheap interim" note — gating reEmitLookahead on CurrentDutyDependentRootChanged is unsafe (prefs span current+next epoch), so it belongs with the full §5 fix. --- EPBS_IMPLEMENTATION_PLAN.md | 40 +++++++++++++++++++ .../v2/ssv/runner/proposer_preferences.go | 5 +++ 2 files changed, 45 insertions(+) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 239bbcceeb..834917f230 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -56,6 +56,39 @@ Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Trea --- +## 1b. Revisit-later TODOs — consolidated index + +The single canonical list of "revisit at a later date" items; detail lives at the `§`/file pointers. **New TODOs land here**, not sprinkled inline. Migrate this list into the #2901 description when this doc is removed (per the top-of-file note). Verify each against the code before acting — some inline notes may have closed since. + +**On the first devnet run / verification** +- [ ] **§2 Fulu-tag attestation** — confirm a Gloas BN accepts the Fulu-tagged attestation submission on Gloas slots; if rejected, extend `BeaconForkAtEpoch` → `DataVersionGloas` (the `TODO(gloas)` in `networkconfig/beacon.go`). *High if it fails — every attestation would.* (T4, ~line 168) +- [ ] **§4/§6 stateless Contents** — confirm the §6 blinded-vs-`Contents` body choice; wire `SignedExecutionPayloadEnvelopeContents` (envelope + blobs + KZG) only if a devnet BN runs payload-stateless (also un-defers T7's blob plumbing). (§7 "Remaining"; finding #2) +- [ ] **QuickTimeout** — RTT-tune the 2s round budget / decide on restoring the round-2 proposer fallback from devnet data. (T3 note; §7 timing audit) +- [ ] **Telemetry on devnet** — add the G5 PTC-non-convergence log once gauged; revisit §6 priority + any no-QBFT tuning against real local-build / recon-miss rates. (§8; §2b) +- [ ] **devnet network stubs** — fill `RegistryContractAddr`, `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs). (§7 checklist step 5) +- [ ] **ssv-mini monitor** — flip `monitor.enabled: true` in `params-gloas.yaml` once ethereum2-monitor#504 is in the image. (§7 local_testnet step 3) + +**On the SIP-94 §5 decision (cross-client — blocked)** +- [ ] **§5 re-emission** — validation: dedup `ProposerPreferencesPartialSig` by `(slot, signer, root)` up to a bound N (proposed 4); handler: re-emit only on a real `dependent_root` change (needs per-slot root tracking). Must land in SIP-94 §5 + be matched by Anchor. (§7 DEFERRED; finding #1) +- [ ] **§5 publish-finality** — hold publication until `dependent_root`/`fee_recipient`/`target_gas_limit` are final. `KNOWN ISSUE` comment now in `buildProposerPreferences`; implement the hold only if it bites on devnet. (finding #3) + +**On upstream / cross-client maturity** +- [ ] **go-eth2-client Gloas** — swap the hand-rolled types/HTTP for upstream `spec/gloas` when it ships (a dedup, not a gate). (§2b; U2) +- [ ] **Web3Signer ePBS duties** — route PTC / preferences / envelope signing via Web3Signer when it adds the types (the `TODO(gloas)` in `ssvsigner/ekm/remote_key_manager.go`). (§2b) +- [ ] **Anchor §5/§6 constants** — re-check wire constants vs sigp/anchor once it builds §5/§6 (PTC already verified). (§2b; §4 seq) +- [ ] **consensus-specs pin** — re-verify at each milestone (SIP watchlist tracks normative drift). (§2b) +- [ ] **HTR spec vectors** — run the computational Gloas SSZ cross-check against canonical vectors once the fork ships (none exist yet). (T8) +- [ ] **SIP-94 §4/§6 text** — reconcile the SIP to the merged beacon-APIs#580 flow (`include_payload=false` + blinded) that the impl tracks. (finding #2) +- [ ] **EIP-8282** — add node-side Gloas `ExecutionRequests` (builder deposit/exit) + HTR-parity vectors if/when a target devnet adopts it. (T8 review) + +**On Boole → stage landing** +- [ ] `git rebase --onto stage epbs-gloas` to move the ePBS commits when Boole merges. (§6) + +**Investigated & closed — do not revisit** +- **§2 slashing-index** — the Gloas payload-status index passed to `IsAttestationSlashable` is inert (SSV's slashing protection is epoch-only; verified in eth2-key-manager). No action. (finding #4) + +--- + ## 2. Resolved investigations (findings + decisions) ### U0 — How the new protocol types enter the node **(decided — incl. the ssv-spec posture)** @@ -362,6 +395,13 @@ PTC is implemented node-side end-to-end (wire types → goclient endpoints → e **Committed on `epbs-gloas`** (rebased onto the refreshed `boole-fork` — see §6): the PTC implementation (above); two review rounds — first the `DataVersionGloas` → `networkconfig` / `BeaconForkAtEpoch` TODO / SSZ-regen tidy-up, then the 11-point PTC code review (unmasked-address requests, per-client timeouts, transient-BN warn, cutoff-baselined lateness, `signSSZRoot`, abstain semantics, handler tests); the `GlamsterdamDevnet` networkconfig stub; a `.dockerignore` `tla/` exclusion. **P1 image `ssvnode:epbs-gloas` builds + runs** (verified). +### PR review round (2026-07-01) — §2/§4/§5/§6 spec-alignment sweep +A second review pass over the ePBS submit paths (findings #1–#4). **None are functional runtime bugs** — all are spec-alignment / SIP-coordination / documentation items. Only one new TODO (§5 publish-finality); the rest confirm or cross-ref items already tracked. +- **§5 publish-finality guard — NEW TODO; the one gap with no in-code note.** SIP-94 §5 says hold publication until a proposal slot's `dependent_root` / `fee_recipient` / `target_gas_limit` are final. The runner does not: `buildProposerPreferences` reads them at emit time and the preference is published on pre-consensus quorum (`protocol/v2/ssv/runner/proposer_preferences.go`), so it can publish on a non-final `dependent_root` — and, per the re-emission DEFERRED block above, can't be corrected afterward. Reorg-gated + §5 is observational → low severity. **Done:** `KNOWN ISSUE` comment added in `buildProposerPreferences` for parity with the re-emission one; implement the finality hold only if it bites on devnet. *(Promotes the "ProposerPreferences publish-finality follow-up" from the top-of-file delete-note into a tracked item — see §1b.)* +- **§5 re-emission (finding #1) — already tracked** (DEFERRED block above). Correction to the earlier "cheap interim" idea: `reEmitLookahead` does fire on **every** reorg (not just `dependent_root` changes), but gating it on `ReorgEvent.CurrentDutyDependentRootChanged` is **not** a safe one-liner — preferences span the current **and** next epoch, and that flag covers only the current epoch (the proposer handler always re-fetches the next epoch on any reorg), so a naive gate would suppress legitimate next-epoch refreshes. The `dependent_root`-change gate therefore belongs **with** the full §5 fix (same per-slot root tracking), not as a standalone interim. +- **§4/§6 stateless Contents (finding #2) — already tracked** (§7 "Remaining" above; `include_payload=false` + blinded/stateful publish, #580-pinned). Reconciliation angle: the divergence from SIP-94's `BlockContents` / `…EnvelopeContents` flow is deliberate — it tracks the *merged* beacon-APIs#580 that real BNs serve — so the fix is a **SIP-text update** (its watchlist authorizes it), not wiring Contents. Wire Contents only if a devnet BN proves payload-stateless. +- **§2 slashing-index (finding #4) — investigated, non-issue (no code action).** The Gloas payload-status index passed to `IsAttestationSlashable` (`value_check.go`) is inert: SSV's slashing protection (eth2-key-manager `NewNormalProtection`) compares **only** `source`/`target` epochs and explicitly stores no signing roots (verified in the lib). The code comment already states this and is accurate. At most a SIP-text rationale nuance. + ### Gate check — PASSED Make-or-break question for the public-devnet path: do the Gloas devnet CL clients expose the **beacon-API PTC validator endpoints**? (A Gloas chain can run with built-in VCs doing PTC internally without exposing them to an external VC like SSV.) They do: - **Lodestar** `packages/api/src/beacon/routes/validator.ts` defines `getPtcDuties` (`/eth/v1/validator/duties/ptc/{epoch}`) and `producePayloadAttestationData` (→ `gloas.PayloadAttestationData`) — the exact URLs `beacon/goclient/ptc.go` calls. **Lighthouse** has the endpoints in `common/eth2` + a `payload_attestation_service`. diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 636153c9d4..936ef99c9f 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -385,6 +385,11 @@ func (r *proposerPreferencesSlotRunner) buildProposerPreferences(ctx context.Con return nil, fmt.Errorf("could not fetch proposer-duties dependent root for epoch %d: %w", epoch, err) } + // KNOWN ISSUE (SIP-94 §5 publish-finality — pending): dependent_root/fee_recipient/target_gas_limit are + // read here at emit time and the preference is published once pre-consensus quorum is reached, with no + // guard holding publication until they are final. A reorg can change dependent_root afterwards — and the + // ≤1-per-(slot,signer) pre-consensus dedup means the refresh can't be re-emitted (see the reEmitLookahead + // KNOWN ISSUE). Low severity (reorg-gated, §5 is observational); add a finality hold only if it bites on devnet. return &gloas.ProposerPreferences{ DependentRoot: dependentRoot, ProposalSlot: proposalSlot, From 65cc483c8d77475a429acd12672a5448cb7439c5 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 16:07:12 +0300 Subject: [PATCH 085/150] gloas: add proposer slot-dispatch + late-fetch diagnostics (ssvlabs/ssv#2901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two TEMP logging-only diagnostics to pinpoint where an assigned Gloas proposer duty is lost between fetch and the runner. The existing zero-eligible diagnostic only covers the "never fetched" case; these cover the loaded-epoch case: - logSlotDispatchDiagnostic (processExecution): on any slot carrying a stored proposer duty, reports stored_any / in_committee / executable so a run can separate an InCommittee-flag drop from a one-slot-window miss from a downstream dispatch loss (cross-checked against the existing 🔧 executing validator duty / could not find validator logs). - logFetchDispatchDiagnostic (fetchAndProcessDuties): flags in-committee duties stored for already-passed slots (fetched-too-late), and surfaces the InCommittee split on the fetch-success path. Read-only; no control-flow change. Remove after devnet confirmation. --- operator/duties/proposer.go | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index 53841c98c2..18d189a98d 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -312,6 +312,10 @@ func (h *ProposerHandler) processExecution(ctx context.Context, epoch phase0.Epo defer span.End() duties := h.duties.CommitteeSlotDuties(epoch, slot) + + // TEMP(ssvlabs/ssv#2901): per-slot dispatch diagnostic — remove after devnet confirmation. + h.logSlotDispatchDiagnostic(epoch, slot, duties) + if duties == nil { span.AddEvent("no duties available") span.SetStatus(codes.Ok, "") @@ -398,6 +402,9 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap span.AddEvent("storing duties", trace.WithAttributes(observability.DutyCountAttribute(len(storeDuties)))) h.duties.Set(targetEpoch, storeDuties) + // TEMP(ssvlabs/ssv#2901): late-fetch / InCommittee-on-success diagnostic — remove after devnet confirmation. + h.logFetchDispatchDiagnostic(logger, targetEpoch, currentSlot, storeDuties) + truncate := -1 if h.exporterMode { truncate = 10 @@ -449,6 +456,90 @@ func (h *ProposerHandler) logNoEligibleDiagnostic(logger *zap.Logger, targetEpoc ) } +// logSlotDispatchDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901) for the "every proposer slot +// missed on the Gloas devnet" investigation. On any slot for which this node holds a stored proposer duty +// it records how the duty flows through the two execution gates — InCommittee (CommitteeSlotDuties) and +// shouldExecute (the one-slot execution window) — so a devnet run can tell apart the candidate causes of a +// missed proposal without guessing: +// +// stored_any>0, in_committee=0 → stored but dropped by the InCommittee flag (the PR-comment hypothesis) +// in_committee>0, executable=0 → in-committee but outside the one-slot window (resolved/fetched too late) +// executable>0 → dispatched to the runner; any remaining loss is downstream (see the +// "🔧 executing validator duty" / "could not find validator" logs) +// +// It fires only on slots that actually carry a stored duty (SlotIndices short-circuits otherwise), so it is +// not per-slot noise. Remove once the root cause is confirmed on devnet. +func (h *ProposerHandler) logSlotDispatchDiagnostic(epoch phase0.Epoch, slot phase0.Slot, inCommittee []*eth2apiv1.ProposerDuty) { + storedAny := h.duties.SlotIndices(epoch, slot) + if len(storedAny) == 0 { + return // no proposer duty stored for this slot — nothing was assigned to us here, nothing to diagnose + } + + // Mirror shouldExecute's window (currentSlot == or +1 == duty.Slot) WITHOUT its warnMisalignedSlotAndDuty + // side effect, so the diagnostic never double-logs the misalignment warning the real dispatch loop emits. + currentSlot := h.netCfg.EstimatedCurrentSlot() + executable := 0 + for _, d := range inCommittee { + if currentSlot == d.Slot || currentSlot+1 == d.Slot { + executable++ + } + } + + storedIdx := make([]uint64, len(storedAny)) + for i, idx := range storedAny { + storedIdx[i] = uint64(idx) + } + + h.logger.Debug("🔬 proposer slot dispatch (diagnostic)", + zap.Uint64("epoch", uint64(epoch)), + zap.Uint64("slot", uint64(slot)), + zap.Uint64("current_slot", uint64(currentSlot)), + zap.Int("stored_any", len(storedAny)), + zap.Int("in_committee", len(inCommittee)), + zap.Int("executable", executable), + zap.Uint64s("stored_indices", storedIdx), + ) +} + +// logFetchDispatchDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901). Right after a fetch stores an +// epoch's proposer duties it reports, for THIS node's in-committee duties, how many are for slots that have +// already passed at fetch time (current_slot) — proposals whose one-slot execution window is already gone +// (the "fetched too late" failure the retry fix does not address). It also surfaces the InCommittee split on +// the fetch-SUCCESS path, which logNoEligibleDiagnostic (zero-eligible only) cannot see — so a run can +// directly confirm or refute InCommittee=0 at loaded epochs. Remove once the root cause is confirmed. +func (h *ProposerHandler) logFetchDispatchDiagnostic(logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot, stored []dutystore.StoreDuty[eth2apiv1.ProposerDuty]) { + inCommittee := 0 + alreadyPassed := make([]uint64, 0) + for _, d := range stored { + if !d.InCommittee { + continue + } + inCommittee++ + if d.Slot < currentSlot { + alreadyPassed = append(alreadyPassed, uint64(d.Slot)) + } + } + + // Fetched some duties, but none belong to this node — the InCommittee=0 case the PR comment posits. + if len(stored) > 0 && inCommittee == 0 { + logger.Debug("🔬 proposer fetch: stored duties but none in-committee (diagnostic)", + zap.Uint64("target_epoch", uint64(targetEpoch)), + zap.Int("stored_total", len(stored)), + ) + } + + // Fetched in-committee duties for slots that already passed — a guaranteed miss (fetched too late). + if len(alreadyPassed) > 0 { + logger.Warn("🔬 proposer fetch: in-committee duties for already-passed slots (diagnostic)", + zap.Uint64("target_epoch", uint64(targetEpoch)), + zap.Uint64("current_slot", uint64(currentSlot)), + zap.Int("in_committee_total", inCommittee), + zap.Int("already_passed", len(alreadyPassed)), + zap.Uint64s("passed_slots", alreadyPassed), + ) + } +} + func (h *ProposerHandler) toSpecDuty(duty *eth2apiv1.ProposerDuty, role spectypes.BeaconRole) *spectypes.ValidatorDuty { return &spectypes.ValidatorDuty{ Type: role, From 9d417b94c35aa2d94ef53515259efce29731a5d0 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 16:41:21 +0300 Subject: [PATCH 086/150] =?UTF-8?q?gloas:=20plan=20=E2=80=94=20track=20rem?= =?UTF-8?q?ote-signing=20(Web3Signer)=20breakage=20RS-1/2/3=20accurately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §2b row, §1b index, and a new §7 subsection now reflect the full remote- signing breakage on Gloas (wider than the old "3 new duties" note): - RS-1: fork_info carries the Fulu version on Gloas (GetForkInfo→BeaconForkAtEpoch caps at Fulu) → every non-pinned remote duty gets the wrong domain. Node-fixable, highest leverage. - RS-2: §4 block hits the converter's "obj type is unknown" default. - RS-3: §3/§5/§6 have no Web3Signer request type (upstream-blocked). RS-1's fix is corrected to the scoped ForkAtVersion(spec.DataVersionFulu+1) approach — no interface change, no direct Forks access, no BeaconForkAtEpoch ripple; notes the ssvsigner module boundary (can't import networkconfig's Gloas data version). Local signing unaffected; impact is fail-safe (liveness). --- EPBS_IMPLEMENTATION_PLAN.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 834917f230..0c41bc8836 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -72,9 +72,13 @@ The single canonical list of "revisit at a later date" items; detail lives at th - [ ] **§5 re-emission** — validation: dedup `ProposerPreferencesPartialSig` by `(slot, signer, root)` up to a bound N (proposed 4); handler: re-emit only on a real `dependent_root` change (needs per-slot root tracking). Must land in SIP-94 §5 + be matched by Anchor. (§7 DEFERRED; finding #1) - [ ] **§5 publish-finality** — hold publication until `dependent_root`/`fee_recipient`/`target_gas_limit` are final. `KNOWN ISSUE` comment now in `buildProposerPreferences`; implement the hold only if it bites on devnet. (finding #3) +**Remote signing (Web3Signer) on Gloas — broken for all duties (detail + fixes in §7)** +- [ ] **`fork_info` Gloas version (RS-1)** — scoped Gloas fork in `GetForkInfo` (via existing `ForkAtVersion`; ssvsigner-local Gloas data version); **node-side, do first** — unblocks all existing remote duties. (§7 Remote-signing) +- [ ] **§4 block converter (RS-2)** — add the `*gloas.BeaconBlock` case to `ConvertBlockToBeaconBlockData` (+RS-1); Gloas block-version acceptance is cross-system. (§7 Remote-signing) +- [ ] **§3/§5/§6 Web3Signer types (RS-3)** — upstream-blocked (Web3Signer must add payload-attestation / proposer-preferences / envelope types); local-sign meanwhile, bounded by `f`. (§7 Remote-signing) + **On upstream / cross-client maturity** - [ ] **go-eth2-client Gloas** — swap the hand-rolled types/HTTP for upstream `spec/gloas` when it ships (a dedup, not a gate). (§2b; U2) -- [ ] **Web3Signer ePBS duties** — route PTC / preferences / envelope signing via Web3Signer when it adds the types (the `TODO(gloas)` in `ssvsigner/ekm/remote_key_manager.go`). (§2b) - [ ] **Anchor §5/§6 constants** — re-check wire constants vs sigp/anchor once it builds §5/§6 (PTC already verified). (§2b; §4 seq) - [ ] **consensus-specs pin** — re-verify at each milestone (SIP watchlist tracks normative drift). (§2b) - [ ] **HTR spec vectors** — run the computational Gloas SSZ cross-check against canonical vectors once the fork ships (none exist yet). (T8) @@ -160,7 +164,7 @@ Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=fal | **go-eth2-client Gloas support** | Absent upstream | Build full Gloas types + endpoint clients **node-side now** (T2); swap for upstream `spec/gloas` as a later **dedup** when it ships — not a gate | | **produceBlockV4 + envelope endpoints** | beacon-APIs#580 unmerged, may churn | Implement node-side against #580; pin + watch for churn; e2e on the local Gloas devnet (T2/T7/T8) | | **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | -| **Remote-signer (Web3Signer) ePBS duties** | Web3Signer has no PTC / proposer-preferences / envelope sign types | `RemoteKeyManager` returns a descriptive error per domain (`ssvsigner/ekm/remote_key_manager.go`); a remote-signing operator can't sign the three new duties — **bounded by `f`** (cluster reconstructs while ≤ f are remote-signing), but those operators must **local-sign the affected validators** and will emit recurring `⚠️ duty failed` noise until then. Operator-facing — surface in the PR description / operator notes. `TODO(gloas)`: route via Web3Signer when it adds the types. | +| **Remote-signer (Web3Signer) on Gloas — broken for all duties** | Three layers (full detail + fixes in §7 "Remote (Web3Signer) signing on Gloas"): (1) `fork_info` carries the **Fulu** version on Gloas (`GetForkInfo`→`BeaconForkAtEpoch` caps at Fulu) → **every non-pinned remote duty** (attestation/sync/aggregation/block) gets the wrong domain; (2) §4 block hits the converter's `obj type is unknown` default (no `*gloas.BeaconBlock` case); (3) §3/§5/§6 have no Web3Signer request type. | (1) **node-fixable now, highest leverage** — scoped Gloas `fork_info` in `GetForkInfo` (via the existing `ForkAtVersion` interface method; ssvsigner defines the Gloas data version locally, module boundary), unblocks all existing remote duties. (2) **node-fixable** — add the converter case (+#1); Gloas block-version acceptance is cross-system. (3) **upstream-blocked** — Web3Signer must add the types; local-sign meanwhile, **bounded by `f`**. Local signing unaffected; fail-safe (liveness). Voluntary-exit + validator-registration are domain-pinned → exempt from (1). Operator-facing — surface in the PR description. | | **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | | **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | | **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13) — **nice-to-have viz; primary validation is the §8 greppable logs** — revisit §6 priority and any no-QBFT tuning post-deploy | @@ -402,6 +406,12 @@ A second review pass over the ePBS submit paths (findings #1–#4). **None are f - **§4/§6 stateless Contents (finding #2) — already tracked** (§7 "Remaining" above; `include_payload=false` + blinded/stateful publish, #580-pinned). Reconciliation angle: the divergence from SIP-94's `BlockContents` / `…EnvelopeContents` flow is deliberate — it tracks the *merged* beacon-APIs#580 that real BNs serve — so the fix is a **SIP-text update** (its watchlist authorizes it), not wiring Contents. Wire Contents only if a devnet BN proves payload-stateless. - **§2 slashing-index (finding #4) — investigated, non-issue (no code action).** The Gloas payload-status index passed to `IsAttestationSlashable` (`value_check.go`) is inert: SSV's slashing protection (eth2-key-manager `NewNormalProtection`) compares **only** `source`/`target` epochs and explicitly stores no signing roots (verified in the lib). The code comment already states this and is accurate. At most a SIP-text rationale nuance. +### Remote (Web3Signer) signing on Gloas — broken wider than §2b tracked (RS-1/RS-2/RS-3) +A PR-review sweep of the remote-signing path: the breakage is bigger than the old §2b row (which only covered the three *new* duties). **Local signing is unaffected** throughout — `LocalKeyManager.SignBeaconObject` signs the root computed from the BN-sourced `domain`, never `fork_info`. Impact is **liveness / fail-safe** (a rejected or wrong-domain sign, never a bad on-chain sig), and likely does not affect current devnet runs if those local-sign. Order of work: **RS-1 first** (one scoped change unblocks all existing remote duties), then RS-2, then RS-3 waits on upstream. +- **RS-1 — `fork_info` carries the Fulu version on Gloas → every non-pinned remote duty gets the wrong domain (node-fixable, highest leverage).** `prepareSignRequest` stamps `ForkInfo: GetForkInfo(epoch)` on each request; `GetForkInfo` → `BeaconForkAtEpoch`, whose version list stops at Fulu, so on a Gloas slot it returns the **Fulu** fork/version. Web3Signer derives the domain from `fork_info`, so attestation/sync/aggregation/block partial-sigs sign under the wrong domain → rejected or fail reconstruction (SSV also sends a correct BN-derived `SigningRoot`, so it's a mismatch-reject or wrong-domain sig — either way fail-safe). **Pinned domains exempt:** voluntary-exit (Capella) and validator-registration (genesis) override `fork_info`, so they keep working. **Fix (scoped, no ripple):** resolve the Gloas fork *inside `GetForkInfo`* via the **existing `ForkAtVersion(spec.DataVersionFulu+1)`** interface method — it returns the configured Gloas fork (the real `gloasForkVersion`, populated from the BN spec in `beacon/goclient/spec.go`); gate on `epoch ≥` its fork epoch (= `IsGloas`). **No interface change, no direct `Forks` access.** Do **not** extend `BeaconForkAtEpoch`: its `spec.DataVersion` return feeds ~8 callers (committee/aggregator/goclient submission tags) that deliberately cap at Fulu (its own `TODO(gloas)`). **Module boundary:** the ssvsigner module has its own go.mod and **can't import `networkconfig.DataVersionGloas`**, so it defines the Gloas data version locally as `spec.DataVersionFulu+1` (mirroring the placeholder). `RemoteKeyManager.beaconConfig` is `networkconfig.Beacon` in production (`operator/node.go`); the ssvsigner's own `beaconcfg.Config` (e2e) has the same Fulu cap but only matters if it ever computes signing `fork_info`. **Cross-system (devnet-confirm):** Web3Signer's `compute_domain` is generic over the version bytes, so the correct Gloas `fork_info` should suffice with no Web3Signer Gloas support — the only non-in-repo fact; confirm against a live Web3Signer. +- **RS-2 — §4 remote block signing hits the converter's generic default (node-fixable; needs RS-1; block-version acceptance is cross-system).** `handleDomainProposer` → `ConvertBlockToBeaconBlockData` has no `*gloas.BeaconBlock` case, so it falls through to `default: "obj type is unknown"` (`ssvsigner/web3signer/block_data.go`) — unlike §3/§5/§6's explicit guarded arms; the `version` it passes also comes from `BeaconForkAtEpoch` (Fulu). **Fix:** add the `*gloas.BeaconBlock` case (`BeaconBlockHeader` is fork-agnostic, so BLOCK_V2 can sign it) **and** supply the Gloas version + `fork_info` (RS-1). Unlike RS-1's generic domain, whether Web3Signer accepts the **Gloas block version** on BLOCK_V2 is cross-system — confirm on devnet; may partially gate on Web3Signer Gloas support. +- **RS-3 — §3/§5/§6 have no Web3Signer request type (upstream-blocked; already in §2b).** The PTC/preferences/envelope arms return descriptive errors + `TODO(gloas)`; `SignRequest` has no matching field. Gated on Web3Signer adding `payload_attestation` / `proposer_preferences` / `execution_payload_envelope` types; local-sign meanwhile (bounded by `f`). Point the `TODO(gloas)` arms at the upstream Web3Signer issue. + ### Gate check — PASSED Make-or-break question for the public-devnet path: do the Gloas devnet CL clients expose the **beacon-API PTC validator endpoints**? (A Gloas chain can run with built-in VCs doing PTC internally without exposing them to an external VC like SSV.) They do: - **Lodestar** `packages/api/src/beacon/routes/validator.ts` defines `getPtcDuties` (`/eth/v1/validator/duties/ptc/{epoch}`) and `producePayloadAttestationData` (→ `gloas.PayloadAttestationData`) — the exact URLs `beacon/goclient/ptc.go` calls. **Lighthouse** has the endpoints in `common/eth2` + a `payload_attestation_service`. From c15d848b6946889e739838f6b55d366c2cbc3922 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 16:50:20 +0300 Subject: [PATCH 087/150] gloas: fix remote-signer fork_info on Gloas slots (RS-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteKeyManager.GetForkInfo derived the fork from BeaconForkAtEpoch, whose version list caps at Fulu — so on a Gloas epoch it stamped the Fulu fork/version into every non-pinned Web3Signer request. Web3Signer derives the signing domain from fork_info, so all remote duties (attestation/sync/aggregation/…) would sign under the wrong domain on Gloas → rejected or fail reconstruction (fail-safe: liveness, never a bad on-chain sig; local signing was unaffected). GetForkInfo now substitutes the Gloas fork (via the existing ForkAtVersion, gated on epoch ≥ its fork epoch) when Gloas is configured and active — scoped, no change to BeaconForkAtEpoch's Fulu-capped DataVersion (its ~8 submission-tag callers are untouched). The ssvsigner module can't import networkconfig.DataVersionGloas (separate go.mod), so it mirrors it locally as spec.DataVersionFulu+1. Voluntary-exit (Capella) and validator-registration (genesis) already pin their fork and are unaffected. Test asserts a Gloas-epoch request carries the Gloas version, not the Fulu one BeaconForkAtEpoch returns. --- ssvsigner/ekm/remote_key_manager.go | 11 ++++++++++ ssvsigner/ekm/remote_key_manager_test.go | 28 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index abc6add357..bd5a4c4e0b 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -490,8 +490,19 @@ func (km *RemoteKeyManager) handleDomainProposer( return ret, nil } +// gloasDataVersion mirrors networkconfig.DataVersionGloas — the ssvsigner module has its own go.mod and +// can't import the node-side placeholder. Remove once go-eth2-client ships a real spec.DataVersionGloas. +const gloasDataVersion = spec.DataVersionFulu + 1 + +// GetForkInfo returns the ForkInfo for the epoch's active fork. Web3Signer derives the signing domain +// from it, so on a Gloas epoch it must carry the Gloas fork: ForkAtEpoch's version list caps at Fulu +// (its TODO(gloas)), so it returns the Fulu fork on Gloas — which would make Web3Signer sign every remote +// duty under the wrong (Fulu) domain. Substitute the Gloas fork when it is configured and active. func (km *RemoteKeyManager) GetForkInfo(epoch phase0.Epoch) web3signer.ForkInfo { _, currentFork := km.beaconConfig.ForkAtEpoch(epoch) + if gloasFork, ok := km.beaconConfig.ForkAtVersion(gloasDataVersion); ok && epoch >= gloasFork.Epoch { + currentFork = &gloasFork + } return web3signer.ForkInfo{ Fork: currentFork, diff --git a/ssvsigner/ekm/remote_key_manager_test.go b/ssvsigner/ekm/remote_key_manager_test.go index 2b4e19b1ba..00345fc90e 100644 --- a/ssvsigner/ekm/remote_key_manager_test.go +++ b/ssvsigner/ekm/remote_key_manager_test.go @@ -1574,6 +1574,34 @@ func (s *RemoteKeyManagerTestSuite) TestSignBeaconObjectAdditionalDomains() { }) } +func (s *RemoteKeyManagerTestSuite) TestGetForkInfoUsesGloasForkOnGloasEpoch() { + // testBeaconConfig tops out at Fulu (epoch 6); add a Gloas fork so ForkAtEpoch's Fulu cap is + // observable. On a Gloas epoch, fork_info must carry the Gloas fork — sending the Fulu version would + // make Web3Signer derive the wrong domain and reject/mis-sign every remote duty (RS-1). + cfg := testBeaconConfig() + const gloasEpoch = phase0.Epoch(7) + gloasVersion := phase0.Version{7, 0, 0, 0} + cfg.Forks[gloasDataVersion] = phase0.Fork{ + Epoch: gloasEpoch, + PreviousVersion: phase0.Version{6, 0, 0, 0}, + CurrentVersion: gloasVersion, + } + rm := &RemoteKeyManager{beaconConfig: cfg, genesisRoot: cfg.GenesisValidatorsRoot} + + gloasFI := rm.GetForkInfo(gloasEpoch) + s.Require().NotNil(gloasFI.Fork) + s.Equal(gloasVersion, gloasFI.Fork.CurrentVersion) + _, fuluFork := cfg.ForkAtEpoch(gloasEpoch) // ForkAtEpoch still caps at Fulu + s.Equal(phase0.Version{6, 0, 0, 0}, fuluFork.CurrentVersion) + s.NotEqual(fuluFork.CurrentVersion, gloasFI.Fork.CurrentVersion) + s.Equal(cfg.GenesisValidatorsRoot, gloasFI.GenesisValidatorsRoot) + + // A pre-Gloas epoch is unaffected — still the current (Fulu) fork. + fuluFI := rm.GetForkInfo(6) + s.Require().NotNil(fuluFI.Fork) + s.Equal(phase0.Version{6, 0, 0, 0}, fuluFI.Fork.CurrentVersion) +} + func (s *RemoteKeyManagerTestSuite) TestSignBeaconObjectMoreDomains() { ctx := s.T().Context() From 6d4a891242a6cae8165de2219982b014a065c3a5 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 16:52:09 +0300 Subject: [PATCH 088/150] =?UTF-8?q?gloas:=20plan=20=E2=80=94=20RS-1=20done?= =?UTF-8?q?;=20correct=20RS-2=20(module=20boundary,=20not=20"add=20a=20cas?= =?UTF-8?q?e")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RS-1 (fork_info Gloas fix) marked done. RS-2 corrected: implementation-time investigation shows the ssvsigner module has its own go.mod, doesn't require the main module, and imports no gloas — so ConvertBlockToBeaconBlockData *cannot* name `*gloas.BeaconBlock`; "add a case" isn't possible. The real fix is a design decision spanning both modules (pass the block header, phase0.BeaconBlockHeader, across instead — HTR(header)==HTR(block) so the signing root is unchanged) and is still cross-system-gated on Web3Signer accepting a Gloas BLOCK_V2 → deferred. --- EPBS_IMPLEMENTATION_PLAN.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 0c41bc8836..7ddd83e86b 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -73,8 +73,8 @@ The single canonical list of "revisit at a later date" items; detail lives at th - [ ] **§5 publish-finality** — hold publication until `dependent_root`/`fee_recipient`/`target_gas_limit` are final. `KNOWN ISSUE` comment now in `buildProposerPreferences`; implement the hold only if it bites on devnet. (finding #3) **Remote signing (Web3Signer) on Gloas — broken for all duties (detail + fixes in §7)** -- [ ] **`fork_info` Gloas version (RS-1)** — scoped Gloas fork in `GetForkInfo` (via existing `ForkAtVersion`; ssvsigner-local Gloas data version); **node-side, do first** — unblocks all existing remote duties. (§7 Remote-signing) -- [ ] **§4 block converter (RS-2)** — add the `*gloas.BeaconBlock` case to `ConvertBlockToBeaconBlockData` (+RS-1); Gloas block-version acceptance is cross-system. (§7 Remote-signing) +- [x] **`fork_info` Gloas version (RS-1)** — ✅ DONE: scoped Gloas fork in `GetForkInfo` via the existing `ForkAtVersion` (ssvsigner-local Gloas data version) — unblocks all existing remote duties. (§7 Remote-signing) +- [ ] **§4 block signing (RS-2)** — **DEFERRED (module boundary):** ssvsigner can't name `gloas.BeaconBlock`, so pass the block header (`phase0.BeaconBlockHeader`) across instead (+RS-1); cross-system-gated, bigger than RS-1. (§7 Remote-signing) - [ ] **§3/§5/§6 Web3Signer types (RS-3)** — upstream-blocked (Web3Signer must add payload-attestation / proposer-preferences / envelope types); local-sign meanwhile, bounded by `f`. (§7 Remote-signing) **On upstream / cross-client maturity** @@ -164,7 +164,7 @@ Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=fal | **go-eth2-client Gloas support** | Absent upstream | Build full Gloas types + endpoint clients **node-side now** (T2); swap for upstream `spec/gloas` as a later **dedup** when it ships — not a gate | | **produceBlockV4 + envelope endpoints** | beacon-APIs#580 unmerged, may churn | Implement node-side against #580; pin + watch for churn; e2e on the local Gloas devnet (T2/T7/T8) | | **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | -| **Remote-signer (Web3Signer) on Gloas — broken for all duties** | Three layers (full detail + fixes in §7 "Remote (Web3Signer) signing on Gloas"): (1) `fork_info` carries the **Fulu** version on Gloas (`GetForkInfo`→`BeaconForkAtEpoch` caps at Fulu) → **every non-pinned remote duty** (attestation/sync/aggregation/block) gets the wrong domain; (2) §4 block hits the converter's `obj type is unknown` default (no `*gloas.BeaconBlock` case); (3) §3/§5/§6 have no Web3Signer request type. | (1) **node-fixable now, highest leverage** — scoped Gloas `fork_info` in `GetForkInfo` (via the existing `ForkAtVersion` interface method; ssvsigner defines the Gloas data version locally, module boundary), unblocks all existing remote duties. (2) **node-fixable** — add the converter case (+#1); Gloas block-version acceptance is cross-system. (3) **upstream-blocked** — Web3Signer must add the types; local-sign meanwhile, **bounded by `f`**. Local signing unaffected; fail-safe (liveness). Voluntary-exit + validator-registration are domain-pinned → exempt from (1). Operator-facing — surface in the PR description. | +| **Remote-signer (Web3Signer) on Gloas — broken for all duties** | Three layers (full detail + fixes in §7 "Remote (Web3Signer) signing on Gloas"): (1) `fork_info` carried the **Fulu** version on Gloas (`GetForkInfo`→`BeaconForkAtEpoch` caps at Fulu) → **every non-pinned remote duty** (attestation/sync/aggregation/block) got the wrong domain; (2) §4 block hits the converter's `obj type is unknown` default (the Gloas block type isn't nameable in the separate ssvsigner module); (3) §3/§5/§6 have no Web3Signer request type. | (1) **✅ DONE** — scoped Gloas `fork_info` in `GetForkInfo` (via the existing `ForkAtVersion` interface method; ssvsigner mirrors the Gloas data version locally, module boundary); unblocks all existing remote duties. (2) **deferred, module-boundary design** — pass the block header (`phase0.BeaconBlockHeader`) across instead of the Gloas block (+RS-1); cross-system-gated. (3) **upstream-blocked** — Web3Signer must add the types; local-sign meanwhile, **bounded by `f`**. Local signing unaffected; fail-safe (liveness). Voluntary-exit + validator-registration are domain-pinned → exempt from (1). Operator-facing — surface in the PR description. | | **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | | **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | | **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13) — **nice-to-have viz; primary validation is the §8 greppable logs** — revisit §6 priority and any no-QBFT tuning post-deploy | @@ -408,8 +408,8 @@ A second review pass over the ePBS submit paths (findings #1–#4). **None are f ### Remote (Web3Signer) signing on Gloas — broken wider than §2b tracked (RS-1/RS-2/RS-3) A PR-review sweep of the remote-signing path: the breakage is bigger than the old §2b row (which only covered the three *new* duties). **Local signing is unaffected** throughout — `LocalKeyManager.SignBeaconObject` signs the root computed from the BN-sourced `domain`, never `fork_info`. Impact is **liveness / fail-safe** (a rejected or wrong-domain sign, never a bad on-chain sig), and likely does not affect current devnet runs if those local-sign. Order of work: **RS-1 first** (one scoped change unblocks all existing remote duties), then RS-2, then RS-3 waits on upstream. -- **RS-1 — `fork_info` carries the Fulu version on Gloas → every non-pinned remote duty gets the wrong domain (node-fixable, highest leverage).** `prepareSignRequest` stamps `ForkInfo: GetForkInfo(epoch)` on each request; `GetForkInfo` → `BeaconForkAtEpoch`, whose version list stops at Fulu, so on a Gloas slot it returns the **Fulu** fork/version. Web3Signer derives the domain from `fork_info`, so attestation/sync/aggregation/block partial-sigs sign under the wrong domain → rejected or fail reconstruction (SSV also sends a correct BN-derived `SigningRoot`, so it's a mismatch-reject or wrong-domain sig — either way fail-safe). **Pinned domains exempt:** voluntary-exit (Capella) and validator-registration (genesis) override `fork_info`, so they keep working. **Fix (scoped, no ripple):** resolve the Gloas fork *inside `GetForkInfo`* via the **existing `ForkAtVersion(spec.DataVersionFulu+1)`** interface method — it returns the configured Gloas fork (the real `gloasForkVersion`, populated from the BN spec in `beacon/goclient/spec.go`); gate on `epoch ≥` its fork epoch (= `IsGloas`). **No interface change, no direct `Forks` access.** Do **not** extend `BeaconForkAtEpoch`: its `spec.DataVersion` return feeds ~8 callers (committee/aggregator/goclient submission tags) that deliberately cap at Fulu (its own `TODO(gloas)`). **Module boundary:** the ssvsigner module has its own go.mod and **can't import `networkconfig.DataVersionGloas`**, so it defines the Gloas data version locally as `spec.DataVersionFulu+1` (mirroring the placeholder). `RemoteKeyManager.beaconConfig` is `networkconfig.Beacon` in production (`operator/node.go`); the ssvsigner's own `beaconcfg.Config` (e2e) has the same Fulu cap but only matters if it ever computes signing `fork_info`. **Cross-system (devnet-confirm):** Web3Signer's `compute_domain` is generic over the version bytes, so the correct Gloas `fork_info` should suffice with no Web3Signer Gloas support — the only non-in-repo fact; confirm against a live Web3Signer. -- **RS-2 — §4 remote block signing hits the converter's generic default (node-fixable; needs RS-1; block-version acceptance is cross-system).** `handleDomainProposer` → `ConvertBlockToBeaconBlockData` has no `*gloas.BeaconBlock` case, so it falls through to `default: "obj type is unknown"` (`ssvsigner/web3signer/block_data.go`) — unlike §3/§5/§6's explicit guarded arms; the `version` it passes also comes from `BeaconForkAtEpoch` (Fulu). **Fix:** add the `*gloas.BeaconBlock` case (`BeaconBlockHeader` is fork-agnostic, so BLOCK_V2 can sign it) **and** supply the Gloas version + `fork_info` (RS-1). Unlike RS-1's generic domain, whether Web3Signer accepts the **Gloas block version** on BLOCK_V2 is cross-system — confirm on devnet; may partially gate on Web3Signer Gloas support. +- **RS-1 — `fork_info` carries the Fulu version on Gloas → every non-pinned remote duty gets the wrong domain (node-fixable, highest leverage — ✅ DONE).** `prepareSignRequest` stamps `ForkInfo: GetForkInfo(epoch)` on each request; `GetForkInfo` → `BeaconForkAtEpoch`, whose version list stops at Fulu, so on a Gloas slot it returns the **Fulu** fork/version. Web3Signer derives the domain from `fork_info`, so attestation/sync/aggregation/block partial-sigs sign under the wrong domain → rejected or fail reconstruction (SSV also sends a correct BN-derived `SigningRoot`, so it's a mismatch-reject or wrong-domain sig — either way fail-safe). **Pinned domains exempt:** voluntary-exit (Capella) and validator-registration (genesis) override `fork_info`, so they keep working. **Fix (scoped, no ripple):** resolve the Gloas fork *inside `GetForkInfo`* via the **existing `ForkAtVersion(spec.DataVersionFulu+1)`** interface method — it returns the configured Gloas fork (the real `gloasForkVersion`, populated from the BN spec in `beacon/goclient/spec.go`); gate on `epoch ≥` its fork epoch (= `IsGloas`). **No interface change, no direct `Forks` access.** Do **not** extend `BeaconForkAtEpoch`: its `spec.DataVersion` return feeds ~8 callers (committee/aggregator/goclient submission tags) that deliberately cap at Fulu (its own `TODO(gloas)`). **Module boundary:** the ssvsigner module has its own go.mod and **can't import `networkconfig.DataVersionGloas`**, so it defines the Gloas data version locally as `spec.DataVersionFulu+1` (mirroring the placeholder). `RemoteKeyManager.beaconConfig` is `networkconfig.Beacon` in production (`operator/node.go`); the ssvsigner's own `beaconcfg.Config` (e2e) has the same Fulu cap but only matters if it ever computes signing `fork_info`. **Cross-system (devnet-confirm):** Web3Signer's `compute_domain` is generic over the version bytes, so the correct Gloas `fork_info` should suffice with no Web3Signer Gloas support — the only non-in-repo fact; confirm against a live Web3Signer. +- **RS-2 — §4 remote block signing: NOT a simple "add a case" (module boundary + cross-system; DEFERRED).** `handleDomainProposer` → `ConvertBlockToBeaconBlockData` type-switches on the block, but the Gloas block is `protocol/v2/types/gloas.BeaconBlock` (main module) and **the ssvsigner module has its own go.mod, doesn't require the main module, and imports no `gloas`** — so it *cannot* add a `case *gloas.BeaconBlock:`; the block falls through to `default: "obj type is unknown"` (`ssvsigner/web3signer/block_data.go`). *(The signing root is still computed correctly via `ComputeETHSigningRoot(obj, domain)` on the `ssz.HashRoot` interface; only `req.BeaconBlock` — the header BLOCK_V2 needs — can't be built.)* **Fix (design decision, spans both modules):** the node-side proposer path passes the **block header** (`phase0.BeaconBlockHeader`, a go-eth2-client type nameable in ssvsigner; `HTR(header) == HTR(block)`, so the signing root is unchanged) instead of the full Gloas block; the converter gains a `case *phase0.BeaconBlockHeader:`; `handleDomainProposer` supplies the Gloas version. **Cross-system:** whether Web3Signer accepts a Gloas-version BLOCK_V2 is unverified (may gate on Web3Signer Gloas support). Bigger than RS-1 and cross-system-gated → **deferred**; RS-1 already unblocks every remote duty except the §4 block. - **RS-3 — §3/§5/§6 have no Web3Signer request type (upstream-blocked; already in §2b).** The PTC/preferences/envelope arms return descriptive errors + `TODO(gloas)`; `SignRequest` has no matching field. Gated on Web3Signer adding `payload_attestation` / `proposer_preferences` / `execution_payload_envelope` types; local-sign meanwhile (bounded by `f`). Point the `TODO(gloas)` arms at the upstream Web3Signer issue. ### Gate check — PASSED From 3689e94c3f97594ae9eac5ada6a184d795d3bc8b Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 19:35:59 +0300 Subject: [PATCH 089/150] =?UTF-8?q?fix(gloas):=20pad=20graffiti=20to=2032?= =?UTF-8?q?=20bytes=20in=20the=20=C2=A74=20block=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestGloasBeaconBlock hex-encoded the raw graffiti bytes straight into the produce query, so a graffiti shorter than 32 bytes (e.g. the 11-byte "ssv.network") makes lighthouse reject the whole request with 400 "Invalid query string" — the proposer never gets a block and every Gloas proposer slot is missed. Pad to [32]byte before encoding, mirroring the mature GetBeaconBlock path. Found via an aetheria (proposer) e2e run on local_testnet_gloas; with this fix the block request succeeds. --- beacon/goclient/gloas_proposer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 557c903e82..e1fea7a661 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -50,7 +50,11 @@ func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.Sig // requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { - url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(graffiti)) + // Graffiti must be a full 32-byte value in the query — lighthouse rejects a short one with + // 400 "Invalid query string" (mirror the mature GetBeaconBlock path which pads to [32]byte). + g := [32]byte{} + copy(g[:], graffiti) + url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(g[:])) body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil, nil) if err != nil { return nil, err From edf4999677bd4c8b0d57dbf863beb302815e1512 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 19:36:01 +0300 Subject: [PATCH 090/150] =?UTF-8?q?fix(gloas):=20sign=20the=20=C2=A74=20Gl?= =?UTF-8?q?oas=20block=20on=20the=20local=20key=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After QBFT decided the block, post-consensus signing failed with "obj type is unknown: *gloas.BeaconBlock" and the block was never submitted: LocalKeyManager.signBeaconObject's DomainProposer switch had no Gloas case (the ssvsigner module can't name the node's gloas.BeaconBlock type). The decided block arrives as an ssz.HashRoot, so sign its SSZ root directly via the existing signSSZRoot helper, as the other Gloas domains do. Starting point: this skips block slashing protection (see TODO) — a block proposal is slashable, unlike the other Gloas domains, so that must be added (needs the slot plumbed through SignBeaconObject) before mainnet. Found via an aetheria (proposer) e2e run on local_testnet_gloas; with this and the graffiti fix, validator 64's block lands on-chain. --- ssvsigner/ekm/local_key_manager.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index a8e79bf7e4..f07c2aadf1 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -189,7 +189,14 @@ func (km *LocalKeyManager) signBeaconObject( } return km.signer.SignBlindedBeaconBlock(vBlindedBlock, domain, pubKey[:]) default: - return nil, nil, fmt.Errorf("obj type is unknown: %T", obj) + // Gloas (ePBS) block: go-eth2-client has no Gloas VersionedBeaconBlock for the slashing- + // protected SignBeaconBlock path, and ssvsigner (a separate module) can't import the node's + // *gloas.BeaconBlock to type-switch on it. The decided block arrives as an ssz.HashRoot, so + // sign its SSZ signing root directly, as the other Gloas domains do. + // TODO(gloas): unlike those domains a block proposal IS slashable — add block slashing + // protection (IsBeaconBlockSlashable + a highest-proposal record) before mainnet. That needs + // the slot, currently discarded by the outer SignBeaconObject, so it must be plumbed through. + return signSSZRoot(km.signer, obj, domain, pubKey[:]) } case spectypes.DomainVoluntaryExit: From 0a6e4af6f900293502aea3a04b5fd7e0346e4f20 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 20:42:49 +0300 Subject: [PATCH 091/150] =?UTF-8?q?gloas:=20plan=20=E2=80=94=20devnet=20ru?= =?UTF-8?q?n=202026-07-01=20(=C2=A74=20produces=20on=20devnet-5);=20correc?= =?UTF-8?q?t=20"local=20unaffected"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the aetheria (proposer) e2e result: the retry fix closes the original "no eligible validators" finding, and the two downstream bugs behind it (graffiti 400 e1412f656, local-signer obj-type 3c21d06fa) are fixed on-branch → v64 blocks land on-chain (devnet-5). Corrects the RS-1/RS-2 framing: "local signing unaffected" held only for RS-1 (fork_info/domain); local §4 block signing hit the same obj-type gap (both key managers live in ssvsigner, neither can name *gloas.BeaconBlock). Adds the active frontier to §1b: Gloas local-block slashing-protection gap (the local fix signs the root directly, skipping IsBeaconBlockSlashable — doing next), devnet-6 SSZ drift (invalid SSZ on submit), §6 envelope produce 404, §2 attestation cross-BN committee-index inconsistency. --- EPBS_IMPLEMENTATION_PLAN.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index 7ddd83e86b..a826a9a38a 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -60,6 +60,12 @@ Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Trea The single canonical list of "revisit at a later date" items; detail lives at the `§`/file pointers. **New TODOs land here**, not sprinkled inline. Migrate this list into the #2901 description when this doc is removed (per the top-of-file note). Verify each against the code before acting — some inline notes may have closed since. +**Devnet run 2026-07-01 — §4 produces on devnet-5; active frontier (detail in §7)** +- [ ] **Gloas local block slashing protection** — `3c21d06fa` signs the block root directly, skipping `IsBeaconBlockSlashable` + the highest-proposal record; a block IS slashable. Plumb the slot through `SignBeaconObject` + add it. **Node-side, before mainnet — doing next.** (§7 devnet-run) +- [ ] **devnet-6 SSZ drift** — `could not submit gloas beacon block: invalid SSZ`; the node-side Gloas block SSZ diverges from devnet-6's spec. Find the field diff. (§7 devnet-run; consensus-specs pin drift) +- [ ] **§6 envelope produce 404** — `GetExecutionPayloadEnvelope` 404s even where §4 landed; §6 doesn't complete. Confirm endpoint/timing. (§7 devnet-run) +- [ ] **§2 attestation cross-BN inconsistency** — one `committee index 1; expected 0`; confirm (payload-status-index cross-BN vs transient). (§7 devnet-run) + **On the first devnet run / verification** - [ ] **§2 Fulu-tag attestation** — confirm a Gloas BN accepts the Fulu-tagged attestation submission on Gloas slots; if rejected, extend `BeaconForkAtEpoch` → `DataVersionGloas` (the `TODO(gloas)` in `networkconfig/beacon.go`). *High if it fails — every attestation would.* (T4, ~line 168) - [ ] **§4/§6 stateless Contents** — confirm the §6 blinded-vs-`Contents` body choice; wire `SignedExecutionPayloadEnvelopeContents` (envelope + blobs + KZG) only if a devnet BN runs payload-stateless (also un-defers T7's blob plumbing). (§7 "Remaining"; finding #2) @@ -378,6 +384,19 @@ The ssv-spec migration's handoff gates on **node-side-complete** (including T8's PTC is implemented node-side end-to-end (wire types → goclient endpoints → ekm signing → `PTCAttesterRunner` → `SetupRunners` registration → scheduler handler with the 75% trigger → message validation; ssv-spec ePBS constants via PR ssvlabs/ssv-spec#632, go.mods pinned to its commit). This supersedes the T12 sketch with the concrete plan. +### Devnet run 2026-07-01 — §4 block production WORKS on devnet-5 (retry fix validated); new blockers +An aetheria `(proposer)` e2e run on `local_testnet_gloas` (`GLOAS_FORK_EPOCH=2`, SSV indices 64–73, **LOCAL signing**). **The original "no eligible validators" finding is closed** — the retry fix restores `📚 got duties (PROPOSER)` at loaded epochs, and the `🔬` diagnostic ladder confirmed the duty is *dispatched* (`in_committee=1, executable=1`), i.e. the remaining loss was downstream. Two downstream bugs sat behind it, both **fixed + on the branch**: +- **graffiti 400** (`e1412f656`) — `requestGloasBeaconBlock` didn't pad graffiti to 32 bytes; Lighthouse rejected the produce query. Fixed (pad to `[32]byte`, mirroring `GetBeaconBlock`). +- **local-signer `obj type is unknown: *gloas.BeaconBlock`** (`3c21d06fa`) — `LocalKeyManager.signBeaconObject`'s `DomainProposer` switch had no Gloas case (ssvsigner can't name the node's block type); now signs the SSZ root directly via `signSSZRoot`, like the other Gloas domains. + +With both, **v64's block lands on-chain** (devnet-5 slots 464, 529); §2 attestations work throughout. New findings, tracked in §1b: +- **⚠️ Gloas local block signing now has NO slashing protection** — `3c21d06fa` signs the root directly, skipping `IsBeaconBlockSlashable` + the highest-proposal record; a block proposal *is* slashable (unlike PTC/prefs/envelope). `TODO(gloas)` flagged; needs the slot plumbed through `SignBeaconObject`. **Node-side, must close before mainnet — doing next.** +- **devnet-6: `could not submit gloas beacon block: invalid SSZ`** — produces + signs, but the newer BN rejects the encoding → the node-side Gloas block SSZ has **drifted from devnet-6's spec** (the consensus-specs-pin-drift watchlist materializing). Blocks §4 on the *target* devnet — needs the on-wire field diff. +- **§6 envelope produce 404** — even where §4 landed, `GetExecutionPayloadEnvelope` 404s (endpoint path or block-not-landed timing) → `published envelope = 0`; fails at *produce* (no submit error). §6 doesn't complete yet. +- **§2 attestation cross-BN inconsistency** — one `failed to get attestation data … committee index 1; expected 0; inconsistent result`; likely the §2 payload-status-index cross-BN thing or transient (relates to the "confirm §2 aggregate index" item). + +**Correction to the RS-1/RS-2 framing below:** "local signing unaffected" held only for **RS-1** (the fork_info/domain issue); local §4 **block** signing hit the *same* obj-type gap (now fixed). Both `LocalKeyManager` and `RemoteKeyManager` live in the ssvsigner module, so neither could name `*gloas.BeaconBlock`. + ### Update 2026-06-30 — §4/§5/§6 wired to the merged beacon-APIs (#580) [beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) merged 2026-06-29 and the `proposer_preferences` validator endpoint is in master, so the three endpoints that were abstract/stubbed are now implemented against the real merged paths. (go-eth2-client still has no Gloas types, so they stay hand-rolled HTTP — the typed dedup is unchanged and post-fork-OK. The older T5/T7/T8 notes below predate this and are superseded here.) - **§5 proposer preferences** — `SubmitProposerPreferences` POSTs to `/eth/v1/validator/proposer_preferences` (JSON); the `ErrProposerPreferencesPublishUnavailable` sentinel + the runner skip-branch are removed; goclient test added. ⚠️ The *publish* path is done, but the reorg/`dependent_root` **re-emission** is a separate open issue — see the **DEFERRED** block below. @@ -407,7 +426,7 @@ A second review pass over the ePBS submit paths (findings #1–#4). **None are f - **§2 slashing-index (finding #4) — investigated, non-issue (no code action).** The Gloas payload-status index passed to `IsAttestationSlashable` (`value_check.go`) is inert: SSV's slashing protection (eth2-key-manager `NewNormalProtection`) compares **only** `source`/`target` epochs and explicitly stores no signing roots (verified in the lib). The code comment already states this and is accurate. At most a SIP-text rationale nuance. ### Remote (Web3Signer) signing on Gloas — broken wider than §2b tracked (RS-1/RS-2/RS-3) -A PR-review sweep of the remote-signing path: the breakage is bigger than the old §2b row (which only covered the three *new* duties). **Local signing is unaffected** throughout — `LocalKeyManager.SignBeaconObject` signs the root computed from the BN-sourced `domain`, never `fork_info`. Impact is **liveness / fail-safe** (a rejected or wrong-domain sign, never a bad on-chain sig), and likely does not affect current devnet runs if those local-sign. Order of work: **RS-1 first** (one scoped change unblocks all existing remote duties), then RS-2, then RS-3 waits on upstream. +A PR-review sweep of the remote-signing path: the breakage is bigger than the old §2b row (which only covered the three *new* duties). **Local signing is unaffected by RS-1** (the fork_info/domain issue) — `LocalKeyManager.SignBeaconObject` signs the root computed from the BN-sourced `domain`, never `fork_info`. *(But local §4 **block** signing hit a separate gap — the same `obj type is unknown` block-type switch — now fixed; see the 2026-07-01 devnet-run findings above. "Unaffected throughout" was wrong.)* RS-1's impact is **liveness / fail-safe** (a rejected or wrong-domain sign, never a bad on-chain sig). Order of work: **RS-1 first** (one scoped change unblocks all existing remote duties), then RS-2, then RS-3 waits on upstream. - **RS-1 — `fork_info` carries the Fulu version on Gloas → every non-pinned remote duty gets the wrong domain (node-fixable, highest leverage — ✅ DONE).** `prepareSignRequest` stamps `ForkInfo: GetForkInfo(epoch)` on each request; `GetForkInfo` → `BeaconForkAtEpoch`, whose version list stops at Fulu, so on a Gloas slot it returns the **Fulu** fork/version. Web3Signer derives the domain from `fork_info`, so attestation/sync/aggregation/block partial-sigs sign under the wrong domain → rejected or fail reconstruction (SSV also sends a correct BN-derived `SigningRoot`, so it's a mismatch-reject or wrong-domain sig — either way fail-safe). **Pinned domains exempt:** voluntary-exit (Capella) and validator-registration (genesis) override `fork_info`, so they keep working. **Fix (scoped, no ripple):** resolve the Gloas fork *inside `GetForkInfo`* via the **existing `ForkAtVersion(spec.DataVersionFulu+1)`** interface method — it returns the configured Gloas fork (the real `gloasForkVersion`, populated from the BN spec in `beacon/goclient/spec.go`); gate on `epoch ≥` its fork epoch (= `IsGloas`). **No interface change, no direct `Forks` access.** Do **not** extend `BeaconForkAtEpoch`: its `spec.DataVersion` return feeds ~8 callers (committee/aggregator/goclient submission tags) that deliberately cap at Fulu (its own `TODO(gloas)`). **Module boundary:** the ssvsigner module has its own go.mod and **can't import `networkconfig.DataVersionGloas`**, so it defines the Gloas data version locally as `spec.DataVersionFulu+1` (mirroring the placeholder). `RemoteKeyManager.beaconConfig` is `networkconfig.Beacon` in production (`operator/node.go`); the ssvsigner's own `beaconcfg.Config` (e2e) has the same Fulu cap but only matters if it ever computes signing `fork_info`. **Cross-system (devnet-confirm):** Web3Signer's `compute_domain` is generic over the version bytes, so the correct Gloas `fork_info` should suffice with no Web3Signer Gloas support — the only non-in-repo fact; confirm against a live Web3Signer. - **RS-2 — §4 remote block signing: NOT a simple "add a case" (module boundary + cross-system; DEFERRED).** `handleDomainProposer` → `ConvertBlockToBeaconBlockData` type-switches on the block, but the Gloas block is `protocol/v2/types/gloas.BeaconBlock` (main module) and **the ssvsigner module has its own go.mod, doesn't require the main module, and imports no `gloas`** — so it *cannot* add a `case *gloas.BeaconBlock:`; the block falls through to `default: "obj type is unknown"` (`ssvsigner/web3signer/block_data.go`). *(The signing root is still computed correctly via `ComputeETHSigningRoot(obj, domain)` on the `ssz.HashRoot` interface; only `req.BeaconBlock` — the header BLOCK_V2 needs — can't be built.)* **Fix (design decision, spans both modules):** the node-side proposer path passes the **block header** (`phase0.BeaconBlockHeader`, a go-eth2-client type nameable in ssvsigner; `HTR(header) == HTR(block)`, so the signing root is unchanged) instead of the full Gloas block; the converter gains a `case *phase0.BeaconBlockHeader:`; `handleDomainProposer` supplies the Gloas version. **Cross-system:** whether Web3Signer accepts a Gloas-version BLOCK_V2 is unverified (may gate on Web3Signer Gloas support). Bigger than RS-1 and cross-system-gated → **deferred**; RS-1 already unblocks every remote duty except the §4 block. - **RS-3 — §3/§5/§6 have no Web3Signer request type (upstream-blocked; already in §2b).** The PTC/preferences/envelope arms return descriptive errors + `TODO(gloas)`; `SignRequest` has no matching field. Gated on Web3Signer adding `payload_attestation` / `proposer_preferences` / `execution_payload_envelope` types; local-sign meanwhile (bounded by `f`). Point the `TODO(gloas)` arms at the upstream Web3Signer issue. From b27d1bc843afe25812b59c7dccaf01045a0577e1 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 20:52:55 +0300 Subject: [PATCH 092/150] =?UTF-8?q?gloas:=20add=20block=20slashing=20prote?= =?UTF-8?q?ction=20to=20the=20local=20=C2=A74=20Gloas=20block=20signer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local Gloas block fix (3c21d06fa) signed the SSZ root directly with NO slashing protection — a block proposal IS slashable (unlike PTC/prefs/envelope, which are not). SignBeaconObject now plumbs the slot through to signBeaconObject's Gloas case, which checks IsBeaconBlockSlashable + records the highest proposal before signing, serialized via a new blockProposalLock (walletLock is only RLocked during signing, so the manual check→record→sign needs its own mutex to stay atomic). Mirrors the remote handleDomainProposer and what the lib's SignBeaconBlock does internally for the pre-Gloas block types. Closes the 3c21d06fa TODO. Test: a re-proposal at the same slot is now rejected as slashable. Plan §1b/§7 marked done. --- EPBS_IMPLEMENTATION_PLAN.md | 4 +-- ssvsigner/ekm/local_key_manager.go | 29 ++++++++++++++++----- ssvsigner/ekm/local_key_manager_test.go | 34 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index a826a9a38a..c918c5426c 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -61,7 +61,7 @@ Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Trea The single canonical list of "revisit at a later date" items; detail lives at the `§`/file pointers. **New TODOs land here**, not sprinkled inline. Migrate this list into the #2901 description when this doc is removed (per the top-of-file note). Verify each against the code before acting — some inline notes may have closed since. **Devnet run 2026-07-01 — §4 produces on devnet-5; active frontier (detail in §7)** -- [ ] **Gloas local block slashing protection** — `3c21d06fa` signs the block root directly, skipping `IsBeaconBlockSlashable` + the highest-proposal record; a block IS slashable. Plumb the slot through `SignBeaconObject` + add it. **Node-side, before mainnet — doing next.** (§7 devnet-run) +- [x] **Gloas local block slashing protection** — ✅ DONE: `SignBeaconObject` plumbs the slot through; the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing — closes the `3c21d06fa` TODO. (§7 devnet-run) - [ ] **devnet-6 SSZ drift** — `could not submit gloas beacon block: invalid SSZ`; the node-side Gloas block SSZ diverges from devnet-6's spec. Find the field diff. (§7 devnet-run; consensus-specs pin drift) - [ ] **§6 envelope produce 404** — `GetExecutionPayloadEnvelope` 404s even where §4 landed; §6 doesn't complete. Confirm endpoint/timing. (§7 devnet-run) - [ ] **§2 attestation cross-BN inconsistency** — one `committee index 1; expected 0`; confirm (payload-status-index cross-BN vs transient). (§7 devnet-run) @@ -390,7 +390,7 @@ An aetheria `(proposer)` e2e run on `local_testnet_gloas` (`GLOAS_FORK_EPOCH=2`, - **local-signer `obj type is unknown: *gloas.BeaconBlock`** (`3c21d06fa`) — `LocalKeyManager.signBeaconObject`'s `DomainProposer` switch had no Gloas case (ssvsigner can't name the node's block type); now signs the SSZ root directly via `signSSZRoot`, like the other Gloas domains. With both, **v64's block lands on-chain** (devnet-5 slots 464, 529); §2 attestations work throughout. New findings, tracked in §1b: -- **⚠️ Gloas local block signing now has NO slashing protection** — `3c21d06fa` signs the root directly, skipping `IsBeaconBlockSlashable` + the highest-proposal record; a block proposal *is* slashable (unlike PTC/prefs/envelope). `TODO(gloas)` flagged; needs the slot plumbed through `SignBeaconObject`. **Node-side, must close before mainnet — doing next.** +- **✅ Gloas local block slashing protection — DONE.** `3c21d06fa` initially signed the root directly with no protection (a block *is* slashable, unlike PTC/prefs/envelope); now `SignBeaconObject` plumbs the slot through and the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing. Test: a re-proposal at the same slot is rejected. - **devnet-6: `could not submit gloas beacon block: invalid SSZ`** — produces + signs, but the newer BN rejects the encoding → the node-side Gloas block SSZ has **drifted from devnet-6's spec** (the consensus-specs-pin-drift watchlist materializing). Blocks §4 on the *target* devnet — needs the on-wire field diff. - **§6 envelope produce 404** — even where §4 landed, `GetExecutionPayloadEnvelope` 404s (endpoint path or block-not-landed timing) → `published envelope = 0`; fails at *produce* (no submit error). §6 doesn't complete yet. - **§2 attestation cross-BN inconsistency** — one `failed to get attestation data … committee index 1; expected 0; inconsistent result`; likely the §2 payload-status-index cross-BN thing or transient (relates to the "confirm §2 aggregate index" item). diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index f07c2aadf1..e1753b8a49 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -55,6 +55,11 @@ type LocalKeyManager struct { signer signer.ValidatorSigner operatorDecrypter keys.OperatorDecrypter slashingProtector slashingProtector + + // blockProposalLock serializes the manual slashing check→record→sign for Gloas blocks, which can't go + // through the lib's atomic SignBeaconBlock (see signBeaconObject's Gloas case). walletLock is only + // RLocked during signing, so without this two concurrent same-validator block signs could both pass. + blockProposalLock sync.Mutex } // NewLocalKeyManager returns a new LocalKeyManager. @@ -116,10 +121,10 @@ func (km *LocalKeyManager) SignBeaconObject( obj ssz.HashRoot, domain phase0.Domain, pubKey phase0.BLSPubKey, - _ phase0.Slot, + slot phase0.Slot, signatureDomain phase0.DomainType, ) (spectypes.Signature, phase0.Root, error) { - sig, rootSlice, err := km.signBeaconObject(obj, domain, pubKey, signatureDomain) + sig, rootSlice, err := km.signBeaconObject(obj, domain, pubKey, slot, signatureDomain) if err != nil { return nil, phase0.Root{}, err } @@ -132,6 +137,7 @@ func (km *LocalKeyManager) signBeaconObject( obj ssz.HashRoot, domain phase0.Domain, pubKey phase0.BLSPubKey, + slot phase0.Slot, signatureDomain phase0.DomainType, ) (spectypes.Signature, []byte, error) { km.walletLock.RLock() @@ -192,10 +198,21 @@ func (km *LocalKeyManager) signBeaconObject( // Gloas (ePBS) block: go-eth2-client has no Gloas VersionedBeaconBlock for the slashing- // protected SignBeaconBlock path, and ssvsigner (a separate module) can't import the node's // *gloas.BeaconBlock to type-switch on it. The decided block arrives as an ssz.HashRoot, so - // sign its SSZ signing root directly, as the other Gloas domains do. - // TODO(gloas): unlike those domains a block proposal IS slashable — add block slashing - // protection (IsBeaconBlockSlashable + a highest-proposal record) before mainnet. That needs - // the slot, currently discarded by the outer SignBeaconObject, so it must be plumbed through. + // sign its SSZ signing root directly. + // + // A block proposal IS slashable (unlike the other Gloas domains, which signSSZRoot handles + // unguarded), and signSSZRoot doesn't protect it — so replicate what the lib's SignBeaconBlock + // does internally: check + record the highest proposal, then sign. Use the plumbed-through slot, + // since we can't read it off the opaque block. blockProposalLock makes the check→record→sign + // atomic (walletLock is only RLocked here). Mirrors the remote handleDomainProposer. + km.blockProposalLock.Lock() + defer km.blockProposalLock.Unlock() + if err := km.slashingProtector.IsBeaconBlockSlashable(pubKey, slot); err != nil { + return nil, nil, err + } + if err := km.slashingProtector.UpdateHighestProposal(pubKey, slot); err != nil { + return nil, nil, err + } return signSSZRoot(km.signer, obj, domain, pubKey[:]) } diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 64627416cb..19008076cb 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -337,6 +337,40 @@ func TestSignBeaconObject(t *testing.T) { } } +func TestSignBeaconObjectGloasBlockSlashingProtection(t *testing.T) { + ctx := t.Context() + + operatorPrivateKey, err := keys.GeneratePrivateKey() + require.NoError(t, err) + km := testKeyManager(t, operatorPrivateKey) + + sk1 := &bls.SecretKey{} + require.NoError(t, sk1.SetHexString(sk1Str)) + encryptedSK1, err := operatorPrivateKey.Public().Encrypt([]byte(sk1.SerializeToHexStr())) + require.NoError(t, err) + pk := phase0.BLSPubKey(sk1.GetPublicKey().Serialize()) + require.NoError(t, km.AddShare(ctx, nil, encryptedSK1, pk)) + + lkm := km.(*LocalKeyManager) + + // A Gloas block reaches signBeaconObject's default case (ssvsigner can't name *gloas.BeaconBlock); any + // ssz.HashRoot that isn't a known go-eth2-client block type exercises it — a header is a fine stand-in. + // That path used to signSSZRoot with no slashing protection; the guard must now reject a re-proposal. + proposalSlot := testBeaconConfig().EstimatedCurrentSlot() + minSPProposalSlotGap + 10 + block := &phase0.BeaconBlockHeader{Slot: proposalSlot} + + // First proposal: signs and records the highest proposal. + _, root, err := lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, proposalSlot, spectypes.DomainProposer) + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, root) + + // Re-proposing the same slot is slashable → rejected (proves the highest-proposal record + the + // IsBeaconBlockSlashable guard that the direct signSSZRoot path used to skip). + _, _, err = lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, proposalSlot, spectypes.DomainProposer) + require.Error(t, err) + require.Contains(t, err.Error(), "slashable") +} + func TestRemoveShare(t *testing.T) { require.NoError(t, bls.Init(bls.BLS12_381)) From a51a485c0a25f258821166d003dadff947263936 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 1 Jul 2026 21:17:57 +0300 Subject: [PATCH 093/150] =?UTF-8?q?gloas:=20fetch=20=C2=A72=20attestation?= =?UTF-8?q?=20data=20via=20a=20hand-rolled=20GET=20on=20Gloas=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-eth2-client v0.27.0 hardcodes data.Index==0 for all post-Electra slots and rejects anything else with ErrInconsistentResult. On Gloas, AttestationData.Index is the payload-status view (0=EMPTY / 1=FULL, SIP #94 §2), so a FULL payload — the healthy case — is wrongly rejected and the attestation fails. A devnet run saw it only rarely because payload was mostly EMPTY during that run; on a healthy chain it would break most attestations (HIGH, not transient — corrects the earlier "likely transient" read). fetchAttestationData now routes Gloas slots to a hand-rolled attestation_data GET (mirroring the other hand-rolled Gloas endpoints) that skips the validation and keeps the BN index. GetAttestationData's primary fetch goes through the existing fetchAttestationDataFunc hook (matching the stale-refetch path), so both the committee vote and the aggregate (computeAttestationDataRoot) are covered and the path stays testable. Trades weighted multi-BN selection for first-client on Gloas. Tests: a hand-rolled fetch keeps Index=1; the aggregate test injects via the hook. Plan: §2 marked done (HIGH, not transient); §6 envelope-produce 404 characterized (needs a devnet 404 to resolve). --- EPBS_IMPLEMENTATION_PLAN.md | 8 +++---- beacon/goclient/aggregator_test.go | 13 ++++++----- beacon/goclient/attest.go | 35 +++++++++++++++++++++++++++++- beacon/goclient/attest_test.go | 31 ++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md index c918c5426c..ed538546f8 100644 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ b/EPBS_IMPLEMENTATION_PLAN.md @@ -63,8 +63,8 @@ The single canonical list of "revisit at a later date" items; detail lives at th **Devnet run 2026-07-01 — §4 produces on devnet-5; active frontier (detail in §7)** - [x] **Gloas local block slashing protection** — ✅ DONE: `SignBeaconObject` plumbs the slot through; the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing — closes the `3c21d06fa` TODO. (§7 devnet-run) - [ ] **devnet-6 SSZ drift** — `could not submit gloas beacon block: invalid SSZ`; the node-side Gloas block SSZ diverges from devnet-6's spec. Find the field diff. (§7 devnet-run; consensus-specs pin drift) -- [ ] **§6 envelope produce 404** — `GetExecutionPayloadEnvelope` 404s even where §4 landed; §6 doesn't complete. Confirm endpoint/timing. (§7 devnet-run) -- [ ] **§2 attestation cross-BN inconsistency** — one `committee index 1; expected 0`; confirm (payload-status-index cross-BN vs transient). (§7 devnet-run) +- [ ] **§6 envelope produce 404** — `GetExecutionPayloadEnvelope` 404s even where §4 landed; needs devnet correlation (exact path/body/block-landed) — can't fix node-side blind. (§7 devnet-run) +- [x] **§2 attestation Index rejection** — ✅ DONE: go-eth2-client's post-Electra `data.Index==0` check rejected the Gloas payload-status `Index=1` (FULL — healthy case), failing attestations; fixed via a hand-rolled Gloas `attestation_data` fetch that skips the check. NOT transient. (§7 devnet-run) **On the first devnet run / verification** - [ ] **§2 Fulu-tag attestation** — confirm a Gloas BN accepts the Fulu-tagged attestation submission on Gloas slots; if rejected, extend `BeaconForkAtEpoch` → `DataVersionGloas` (the `TODO(gloas)` in `networkconfig/beacon.go`). *High if it fails — every attestation would.* (T4, ~line 168) @@ -392,8 +392,8 @@ An aetheria `(proposer)` e2e run on `local_testnet_gloas` (`GLOAS_FORK_EPOCH=2`, With both, **v64's block lands on-chain** (devnet-5 slots 464, 529); §2 attestations work throughout. New findings, tracked in §1b: - **✅ Gloas local block slashing protection — DONE.** `3c21d06fa` initially signed the root directly with no protection (a block *is* slashable, unlike PTC/prefs/envelope); now `SignBeaconObject` plumbs the slot through and the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing. Test: a re-proposal at the same slot is rejected. - **devnet-6: `could not submit gloas beacon block: invalid SSZ`** — produces + signs, but the newer BN rejects the encoding → the node-side Gloas block SSZ has **drifted from devnet-6's spec** (the consensus-specs-pin-drift watchlist materializing). Blocks §4 on the *target* devnet — needs the on-wire field diff. -- **§6 envelope produce 404** — even where §4 landed, `GetExecutionPayloadEnvelope` 404s (endpoint path or block-not-landed timing) → `published envelope = 0`; fails at *produce* (no submit error). §6 doesn't complete yet. -- **§2 attestation cross-BN inconsistency** — one `failed to get attestation data … committee index 1; expected 0; inconsistent result`; likely the §2 payload-status-index cross-BN thing or transient (relates to the "confirm §2 aggregate index" item). +- **§6 envelope produce 404 (needs devnet correlation).** `executeDuty` gets the §4-decided block root and calls `GetExecutionPayloadEnvelope(slot, root)` → GET `/eth/v1/validator/execution_payload_envelopes/{slot}/{root}` (`beacon/goclient/gloas_envelope.go`). A 404 means the BN doesn't have that block/envelope yet (timing — the envelope duty may fire before the §4 block is imported) or doesn't serve the #580 endpoint. **Can't resolve node-side blind** — needs the exact 404 from a devnet run: the path the BN echoed, the response body, and whether the §4 block had landed at that point. +- **✅ §2 attestation Index rejection — DONE (was HIGH, NOT transient).** Root cause: go-eth2-client v0.27.0 hardcodes `data.Index == 0` for *all* post-Electra slots and rejects anything else with `ErrInconsistentResult` (`http/attestationdata.go`). On Gloas, `Index` is the payload-status (0=EMPTY / 1=FULL, SIP #94 §2), so a **FULL payload — the healthy case — is wrongly rejected**, failing the attestation. The devnet saw it once only because payload was mostly EMPTY during that run; on a healthy chain it would break most attestations. Fixed: `fetchAttestationData` routes Gloas slots to a hand-rolled `attestation_data` GET (`beacon/goclient/attest.go`) that skips the check and keeps the BN index; the aggregate path (`computeAttestationDataRoot`) benefits too. Trades the weighted multi-BN selection for first-client on Gloas (follow-up if reward impact matters). Test added. **Correction to the RS-1/RS-2 framing below:** "local signing unaffected" held only for **RS-1** (the fork_info/domain issue); local §4 **block** signing hit the *same* obj-type gap (now fixed). Both `LocalKeyManager` and `RemoteKeyManager` live in the ssvsigner module, so neither could name `*gloas.BeaconBlock`. diff --git a/beacon/goclient/aggregator_test.go b/beacon/goclient/aggregator_test.go index aefbc38f85..b51e97ed3e 100644 --- a/beacon/goclient/aggregator_test.go +++ b/beacon/goclient/aggregator_test.go @@ -884,13 +884,14 @@ func TestComputeAttestationDataRoot_GloasKeepsBNIndex(t *testing.T) { expectedRoot, err := attData.HashTreeRoot() require.NoError(t, err) - service := &aggregatorClientMock{} - service.AttestationDataFunc = func(_ context.Context, opts *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) { - require.Equal(t, slot, opts.Slot) - return &api.Response[*phase0.AttestationData]{Data: attData}, nil + client := newAggregatorTestClient(&cfg, &aggregatorClientMock{}) + // On Gloas, GetAttestationData uses a hand-rolled fetch (not go-eth2-client, whose post-Electra + // validation would reject the payload-status Index=1) — inject the BN's data via the fetch hook so this + // test exercises computeAttestationDataRoot's index-keeping independent of the transport. + client.fetchAttestationDataFunc = func(_ context.Context, gotSlot phase0.Slot) (*phase0.AttestationData, error) { + require.Equal(t, slot, gotSlot) + return attData, nil } - - client := newAggregatorTestClient(&cfg, service) root, err := client.computeAttestationDataRoot(t.Context(), slot, 7) require.NoError(t, err) require.Equal(t, expectedRoot, root) diff --git a/beacon/goclient/attest.go b/beacon/goclient/attest.go index 2519c63cbd..bb0519539b 100644 --- a/beacon/goclient/attest.go +++ b/beacon/goclient/attest.go @@ -76,7 +76,9 @@ func (gc *GoClient) GetAttestationData(ctx context.Context, slot phase0.Slot) (* // underlying multi-client fetch carries its own timeout. fetchCtx := context.WithoutCancel(ctx) - attData, err := gc.fetchAttestationData(fetchCtx, slot) + // Via the hook (defaults to fetchAttestationData) so it matches the stale-refetch call below and + // stays overridable in tests — needed now that Gloas routes to a hand-rolled fetch, not go-eth2-client. + attData, err := gc.fetchAttestationDataFunc(fetchCtx, slot) if err != nil { return nil, err } @@ -98,12 +100,43 @@ func (gc *GoClient) GetAttestationData(ctx context.Context, slot phase0.Slot) (* // fetchAttestationData fetches attestation data from beacon node(s). func (gc *GoClient) fetchAttestationData(ctx context.Context, slot phase0.Slot) (*phase0.AttestationData, error) { + if gc.getBeaconConfig().IsGloasAtSlot(slot) { + // go-eth2-client's post-Electra AttestationData enforces data.Index == 0 and rejects anything else + // with ErrInconsistentResult. On Gloas, Index carries the payload-status view (0=EMPTY / 1=FULL, + // SIP #94 §2), so a FULL payload — the healthy case — is wrongly rejected and the attestation fails. + // Hand-roll the fetch to skip that check and keep the BN's index (the signed §2 value). Trades the + // weighted multi-BN selection for first-client, acceptable on Gloas (matches the other Gloas fetches). + return gc.gloasAttestationData(ctx, slot) + } if gc.withWeightedAttestationData { return gc.weightedAttestationData(ctx, slot) } return gc.simpleAttestationData(ctx, slot) } +// gloasAttestationDataPath is the standard attestation-data endpoint; we hand-roll the GET for Gloas +// slots to bypass go-eth2-client's post-Electra "data.Index must be 0" validation (see fetchAttestationData). +const gloasAttestationDataPath = "/eth/v1/validator/attestation_data?slot=%d&committee_index=0" + +func (gc *GoClient) gloasAttestationData(ctx context.Context, slot phase0.Slot) (*phase0.AttestationData, error) { + return firstClientResult(ctx, gc, "AttestationData", http.MethodGet, func(ctx context.Context, addr string) (*phase0.AttestationData, error) { + return requestGloasAttestationData(ctx, ptcHTTPClient, addr, slot) + }) +} + +func requestGloasAttestationData(ctx context.Context, httpClient *http.Client, addr string, slot phase0.Slot) (*phase0.AttestationData, error) { + var resp struct { + Data *phase0.AttestationData `json:"data"` + } + if err := ptcDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(gloasAttestationDataPath, slot), nil, nil, &resp); err != nil { + return nil, err + } + if resp.Data == nil { + return nil, errors.New("no attestation data in response") + } + return resp.Data, nil +} + // verifyAndRefetchIfStale checks attestation data against cached head root. // If mismatch detected, waits briefly then re-fetches. // Returns (attestationData, stale) where stale=true means data may be outdated. diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index f4a47bb803..a327fc92fe 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -3,6 +3,7 @@ package goclient import ( "context" "encoding/hex" + "encoding/json" "fmt" "math/rand" "net/http" @@ -94,6 +95,36 @@ var ( } ) +func TestRequestGloasAttestationData(t *testing.T) { + // Gloas payload-status index FULL (1). go-eth2-client's validated path rejects data.Index != 0 + // post-Electra with ErrInconsistentResult; the hand-rolled Gloas fetch must accept it and keep the + // index (the signed §2 value) so attestations don't fail whenever the payload is present. + data := &phase0.AttestationData{ + Slot: 9, + Index: 1, + BeaconBlockRoot: phase0.Root{0xaa}, + Source: &phase0.Checkpoint{Epoch: 1, Root: phase0.Root{0x01}}, + Target: &phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x02}}, + } + dataJSON, err := json.Marshal(data) + require.NoError(t, err) + + var gotMethod, gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath, gotQuery = r.Method, r.URL.Path, r.URL.RawQuery + _, _ = fmt.Fprintf(w, `{"data":%s}`, dataJSON) + })) + defer srv.Close() + + got, err := requestGloasAttestationData(context.Background(), srv.Client(), srv.URL, 9) + require.NoError(t, err) + require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, "/eth/v1/validator/attestation_data", gotPath) + require.Equal(t, "slot=9&committee_index=0", gotQuery) + require.Equal(t, data, got) + require.EqualValues(t, 1, got.Index) // payload-status index survived (not zeroed or rejected) +} + func TestGoClient_GetAttestationData_Simple(t *testing.T) { const withWeightedAttestationData = false From 426dd0d5c529ee8b75be48a77991c617ead68f28 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 00:01:33 +0300 Subject: [PATCH 094/150] =?UTF-8?q?fix(gloas):=20type=20=C2=A74/=C2=A76=20?= =?UTF-8?q?execution=20requests=20as=20the=20EIP-8282=20five-list=20varian?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gloas block body's ParentExecutionRequests and the §6 envelope's ExecutionRequests were *electra.ExecutionRequests — the three Electra request lists (deposits, withdrawals, consolidations). Glamsterdam (EIP-8282) adds builder_deposits and builder_exits, so a Gloas CL encodes five lists. The three-list type marshaled the §4 block two offsets (8 bytes) short, and lighthouse v8.2.0 (glamsterdam-devnet-6) rejected POST /eth/v2/beacon/blocks as invalid SSZ (InvalidLengthPrefix{len:0, expected:4}). Empty request lists let the three-list decoder read a real Gloas block without erroring, so the drift only surfaced on submit, not on produce. Add a node-side gloas.ExecutionRequests (the Electra three plus BuilderDepositRequest and BuilderExitRequest, list bounds 256/16) and use it for both the §4 ParentExecutionRequests and the §6 envelope, keeping the two identical so the blinded and full envelopes still hash equal. Verified on local_testnet_gloas: §4 block publication passes end-to-end (blocks land canonically) and a real v8.2.0 on-chain block round-trips byte-for-byte through the node's SignedBeaconBlock codec — guarded by beacon_block_wire_test.go against future wire drift. Also correct a stale assertion in gloas_proposer_test.go: the block-produce path pads graffiti to 32 bytes, but the test still expected the short unpadded form (a pre-existing failure on the branch). --- beacon/goclient/gloas_envelope_test.go | 3 +- beacon/goclient/gloas_proposer_test.go | 7 +- protocol/v2/ssv/runner/envelope_test.go | 5 +- protocol/v2/ssv/value_check_test.go | 3 +- protocol/v2/types/gloas/beacon_block.go | 7 +- .../v2/types/gloas/beacon_block_encoding.go | 6 +- protocol/v2/types/gloas/beacon_block_test.go | 3 +- .../v2/types/gloas/beacon_block_wire_test.go | 30 ++ .../types/gloas/execution_payload_envelope.go | 11 +- .../execution_payload_envelope_encoding.go | 11 +- .../gloas/execution_payload_envelope_test.go | 9 +- protocol/v2/types/gloas/execution_requests.go | 40 ++ .../gloas/execution_requests_encoding.go | 497 ++++++++++++++++++ .../gloas/testdata/devnet6_gloas_block.ssz | Bin 0 -> 1367 bytes protocol/v2/types/gloas/testing.go | 3 +- 15 files changed, 596 insertions(+), 39 deletions(-) create mode 100644 protocol/v2/types/gloas/beacon_block_wire_test.go create mode 100644 protocol/v2/types/gloas/execution_requests.go create mode 100644 protocol/v2/types/gloas/execution_requests_encoding.go create mode 100644 protocol/v2/types/gloas/testdata/devnet6_gloas_block.ssz diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go index f2ee82e3a2..9c99e8bc6e 100644 --- a/beacon/goclient/gloas_envelope_test.go +++ b/beacon/goclient/gloas_envelope_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" @@ -22,7 +21,7 @@ var _ beacon.GloasEnvelopeCalls = (*GoClient)(nil) func minimalExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { return &gloas.ExecutionPayloadEnvelope{ Payload: &gloas.ExecutionPayload{}, - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &gloas.ExecutionRequests{}, BuilderIndex: gloas.BuilderIndexSelfBuild, } } diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 226e97c2ad..82d2e0c399 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -5,10 +5,10 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" bitfield "github.com/prysmaticlabs/go-bitfield" "github.com/stretchr/testify/require" @@ -27,7 +27,7 @@ func minimalGloasBlock() *gloas.BeaconBlock { ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{BuilderIndex: gloas.BuilderIndexSelfBuild}}, - ParentExecutionRequests: &electra.ExecutionRequests{}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, }, } } @@ -53,7 +53,8 @@ func TestRequestGloasBeaconBlock(t *testing.T) { require.Equal(t, "/eth/v4/validator/blocks/7", gotPath) require.Equal(t, "false", gotIncludePayload) // bare block; payload ships in the §6 envelope require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th - require.Equal(t, "0x02", gotGraffiti) + // graffiti is padded to a full 32-byte value before hex-encoding (lighthouse rejects a short one). + require.Equal(t, "0x02"+strings.Repeat("00", 31), gotGraffiti) require.Equal(t, "application/octet-stream", gotAccept) require.Equal(t, phase0.Slot(7), got.Slot) } diff --git a/protocol/v2/ssv/runner/envelope_test.go b/protocol/v2/ssv/runner/envelope_test.go index dbaabff0db..0cf84803ed 100644 --- a/protocol/v2/ssv/runner/envelope_test.go +++ b/protocol/v2/ssv/runner/envelope_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" @@ -21,7 +20,7 @@ func envelopeConsensusDataSSZ(t *testing.T, slot phase0.Slot, blockRoot phase0.R t.Helper() blinded := &gloas.BlindedExecutionPayloadEnvelope{ PayloadRoot: phase0.Root{0x09}, - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &gloas.ExecutionRequests{}, BuilderIndex: gloas.BuilderIndexSelfBuild, BeaconBlockRoot: blockRoot, ParentBeaconBlockRoot: phase0.Root{0x08}, @@ -103,7 +102,7 @@ func (b *envelopeTestBeacon) SubmitExecutionPayloadEnvelope(_ context.Context, s func sampleEnvelope() *gloas.ExecutionPayloadEnvelope { return &gloas.ExecutionPayloadEnvelope{ Payload: &gloas.ExecutionPayload{BlockNumber: 42}, - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &gloas.ExecutionRequests{}, BuilderIndex: gloas.BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0xaa}, ParentBeaconBlockRoot: phase0.Root{0xbb}, diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index d8996f83fb..9328cd7390 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -4,7 +4,6 @@ import ( "fmt" "testing" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" @@ -253,7 +252,7 @@ func encodeEnvelopeValue(t *testing.T, slot phase0.Slot, valIdx phase0.Validator t.Helper() blinded := &gloas.BlindedExecutionPayloadEnvelope{ PayloadRoot: phase0.Root{0x09}, - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &gloas.ExecutionRequests{}, BuilderIndex: builderIndex, BeaconBlockRoot: blockRoot, ParentBeaconBlockRoot: phase0.Root{0x08}, diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go index 6d56b2f867..595143189b 100644 --- a/protocol/v2/types/gloas/beacon_block.go +++ b/protocol/v2/types/gloas/beacon_block.go @@ -11,7 +11,7 @@ import ( // Regenerate with `go generate ./...`. -path is the package dir so sszgen resolves the sibling gloas // types the body references; --exclude-objs leaves their (already-generated) SSZ in their own files, // and --output collects only the body types here. Includes track go-eth2-client via `go list -m`. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/altair,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/deneb --objs PayloadAttestation,BeaconBlockBody,BeaconBlock,SignedBeaconBlock --exclude-objs ExecutionPayloadBid,SignedExecutionPayloadBid,PayloadAttestationData --output ./beacon_block_encoding.go" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/altair,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/deneb --objs PayloadAttestation,BeaconBlockBody,BeaconBlock,SignedBeaconBlock --exclude-objs ExecutionPayloadBid,SignedExecutionPayloadBid,PayloadAttestationData,ExecutionRequests,BuilderDepositRequest,BuilderExitRequest --output ./beacon_block_encoding.go" // PayloadAttestation is the aggregated PTC attestation the proposer includes in the block body for the // previous slot's payload (consensus-specs gloas) — distinct from the single-member @@ -40,9 +40,8 @@ type BeaconBlockBody struct { BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `ssz-max:"16"` SignedExecutionPayloadBid *SignedExecutionPayloadBid PayloadAttestations []*PayloadAttestation `ssz-max:"4"` - // electra.ExecutionRequests matches the pinned Gloas spec (6ebb2216c); EIP-8282 (builder - // deposit/exit requests, Glamsterdam) will extend it — swap to a node-side variant then. - ParentExecutionRequests *electra.ExecutionRequests + // Gloas execution requests — the EIP-8282 five-list variant, not electra's three (see execution_requests.go). + ParentExecutionRequests *ExecutionRequests } // BeaconBlock is the Gloas (ePBS) beacon block. diff --git a/protocol/v2/types/gloas/beacon_block_encoding.go b/protocol/v2/types/gloas/beacon_block_encoding.go index 3cc9f1e9ff..f555aafb27 100644 --- a/protocol/v2/types/gloas/beacon_block_encoding.go +++ b/protocol/v2/types/gloas/beacon_block_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 7efcf94f53628815916fc52c0883d2d4262466619f93c2e0ad15e1c60823ded5 +// Hash: d0f3e7c62e3866c9a5addda7dc6eca6ce4403294180e85119360901d134119a9 // Version: 0.1.3 package gloas @@ -522,7 +522,7 @@ func (b *BeaconBlockBody) UnmarshalSSZ(buf []byte) error { { buf = tail[o12:] if b.ParentExecutionRequests == nil { - b.ParentExecutionRequests = new(electra.ExecutionRequests) + b.ParentExecutionRequests = new(ExecutionRequests) } if err = b.ParentExecutionRequests.UnmarshalSSZ(buf); err != nil { return err @@ -570,7 +570,7 @@ func (b *BeaconBlockBody) SizeSSZ() (size int) { // Field (12) 'ParentExecutionRequests' if b.ParentExecutionRequests == nil { - b.ParentExecutionRequests = new(electra.ExecutionRequests) + b.ParentExecutionRequests = new(ExecutionRequests) } size += b.ParentExecutionRequests.SizeSSZ() diff --git a/protocol/v2/types/gloas/beacon_block_test.go b/protocol/v2/types/gloas/beacon_block_test.go index e1784ddb9c..40335bf506 100644 --- a/protocol/v2/types/gloas/beacon_block_test.go +++ b/protocol/v2/types/gloas/beacon_block_test.go @@ -5,7 +5,6 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/deneb" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" bitfield "github.com/prysmaticlabs/go-bitfield" "github.com/stretchr/testify/require" @@ -28,7 +27,7 @@ func TestSignedBeaconBlockRoundTrip(t *testing.T) { AggregationBits: bitfield.NewBitvector512(), Data: &PayloadAttestationData{Slot: 6, PayloadPresent: true}, }}, - ParentExecutionRequests: &electra.ExecutionRequests{}, + ParentExecutionRequests: &ExecutionRequests{}, }, }} b, err := in.MarshalSSZ() diff --git a/protocol/v2/types/gloas/beacon_block_wire_test.go b/protocol/v2/types/gloas/beacon_block_wire_test.go new file mode 100644 index 0000000000..57e62d0777 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block_wire_test.go @@ -0,0 +1,30 @@ +package gloas + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/require" +) + +// devnet6GloasBlockSSZ is a real on-chain Gloas SignedBeaconBlock (slot 66) captured from lighthouse +// v8.2.0 (ethpandaops glamsterdam-devnet-6). Its ParentExecutionRequests carries the full EIP-8282 +// five-list ExecutionRequests (all lists empty on this block). +// +//go:embed testdata/devnet6_gloas_block.ssz +var devnet6GloasBlockSSZ []byte + +// TestSignedBeaconBlockMatchesDevnet6Wire guards that the node's SignedBeaconBlock codec byte-round-trips +// a real Glamsterdam CL's wire format — the check that pins the §4 submit. A future wire drift (a new +// request list, a reordered field) fails here instead of only in a devnet run. +func TestSignedBeaconBlockMatchesDevnet6Wire(t *testing.T) { + var blk SignedBeaconBlock + require.NoError(t, blk.UnmarshalSSZ(devnet6GloasBlockSSZ), "decode real v8.2.0 Gloas block") + require.NotNil(t, blk.Message.Body.ParentExecutionRequests, "Gloas block body carries execution requests") + + out, err := blk.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, len(devnet6GloasBlockSSZ), len(out), "re-marshal length must match the CL wire size") + require.Equal(t, devnet6GloasBlockSSZ, out, + "node re-marshal must byte-match v8.2.0's wire format — a mismatch means the Gloas types drifted from the CL") +} diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index 8b3396e745..aa81e8b968 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -3,14 +3,13 @@ package gloas import ( "fmt" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" ) // Regenerate with `go generate ./...`. -path is the package dir (not just this file) so sszgen resolves // the sibling gloas BuilderIndex the envelope references; --objs limits output to the blinded envelope, // collected into its own _encoding.go. Includes track go-eth2-client via `go list -m`. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,SignedBlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload --output ./execution_payload_envelope_encoding.go" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,SignedBlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload,ExecutionRequests,BuilderDepositRequest,BuilderExitRequest --output ./execution_payload_envelope_encoding.go" // BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 // envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by @@ -20,10 +19,8 @@ import ( // value bounded — a few hundred bytes rather than the full payload's hundreds of KB to ~MB. type BlindedExecutionPayloadEnvelope struct { PayloadRoot phase0.Root `ssz-size:"32"` - // electra.ExecutionRequests matches the pinned Gloas spec (consensus-specs 6ebb2216c). EIP-8282 - // (builder deposit/exit requests, slated for Glamsterdam) will extend it — swap to a node-side Gloas - // variant when the target devnet adopts it. - ExecutionRequests *electra.ExecutionRequests + // Gloas execution requests — the EIP-8282 five-list variant, not electra's three (see execution_requests.go). + ExecutionRequests *ExecutionRequests BuilderIndex BuilderIndex BeaconBlockRoot phase0.Root `ssz-size:"32"` ParentBeaconBlockRoot phase0.Root `ssz-size:"32"` @@ -39,7 +36,7 @@ func (b *BlindedExecutionPayloadEnvelope) Decode(data []byte) error { return b.U // so the signature over the blinded root is valid for this full envelope. type ExecutionPayloadEnvelope struct { Payload *ExecutionPayload - ExecutionRequests *electra.ExecutionRequests + ExecutionRequests *ExecutionRequests BuilderIndex BuilderIndex BeaconBlockRoot phase0.Root `ssz-size:"32"` ParentBeaconBlockRoot phase0.Root `ssz-size:"32"` diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go index 26722be088..82d2357643 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -1,10 +1,9 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 73846c48b7cde91ed52a80435c1ba2fb557db5b699b2588ca331a2d26c43493f +// Hash: d0f3e7c62e3866c9a5addda7dc6eca6ce4403294180e85119360901d134119a9 // Version: 0.1.3 package gloas import ( - "github.com/attestantio/go-eth2-client/spec/electra" ssz "github.com/ferranbt/fastssz" ) @@ -77,7 +76,7 @@ func (b *BlindedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { { buf = tail[o1:] if b.ExecutionRequests == nil { - b.ExecutionRequests = new(electra.ExecutionRequests) + b.ExecutionRequests = new(ExecutionRequests) } if err = b.ExecutionRequests.UnmarshalSSZ(buf); err != nil { return err @@ -92,7 +91,7 @@ func (b *BlindedExecutionPayloadEnvelope) SizeSSZ() (size int) { // Field (1) 'ExecutionRequests' if b.ExecutionRequests == nil { - b.ExecutionRequests = new(electra.ExecutionRequests) + b.ExecutionRequests = new(ExecutionRequests) } size += b.ExecutionRequests.SizeSSZ() @@ -225,7 +224,7 @@ func (e *ExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { { buf = tail[o1:] if e.ExecutionRequests == nil { - e.ExecutionRequests = new(electra.ExecutionRequests) + e.ExecutionRequests = new(ExecutionRequests) } if err = e.ExecutionRequests.UnmarshalSSZ(buf); err != nil { return err @@ -246,7 +245,7 @@ func (e *ExecutionPayloadEnvelope) SizeSSZ() (size int) { // Field (1) 'ExecutionRequests' if e.ExecutionRequests == nil { - e.ExecutionRequests = new(electra.ExecutionRequests) + e.ExecutionRequests = new(ExecutionRequests) } size += e.ExecutionRequests.SizeSSZ() diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go index 9a8f376f6d..75ba34b2cc 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_test.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -5,7 +5,6 @@ import ( "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/phase0" "github.com/stretchr/testify/require" ) @@ -14,7 +13,7 @@ import ( func TestBlindedExecutionPayloadEnvelopeRoundTrip(t *testing.T) { in := &BlindedExecutionPayloadEnvelope{ PayloadRoot: phase0.Root{0x01}, - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &ExecutionRequests{}, BuilderIndex: BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0x02}, ParentBeaconBlockRoot: phase0.Root{0x03}, @@ -79,7 +78,7 @@ func TestExecutionPayloadRoundTrip(t *testing.T) { func TestExecutionPayloadEnvelopeRoundTrip(t *testing.T) { in := &ExecutionPayloadEnvelope{ Payload: sampleExecutionPayload(), - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &ExecutionRequests{}, BuilderIndex: BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0x02}, ParentBeaconBlockRoot: phase0.Root{0x03}, @@ -102,7 +101,7 @@ func TestExecutionPayloadEnvelopeRoundTrip(t *testing.T) { func TestExecutionPayloadEnvelopeBlindsToSameRoot(t *testing.T) { full := &ExecutionPayloadEnvelope{ Payload: sampleExecutionPayload(), - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &ExecutionRequests{}, BuilderIndex: BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0x02}, ParentBeaconBlockRoot: phase0.Root{0x03}, @@ -125,7 +124,7 @@ func TestSignedExecutionPayloadEnvelopeBlindedRoundTrip(t *testing.T) { full := &SignedExecutionPayloadEnvelope{ Message: &ExecutionPayloadEnvelope{ Payload: sampleExecutionPayload(), - ExecutionRequests: &electra.ExecutionRequests{}, + ExecutionRequests: &ExecutionRequests{}, BuilderIndex: BuilderIndexSelfBuild, BeaconBlockRoot: phase0.Root{0x02}, ParentBeaconBlockRoot: phase0.Root{0x03}, diff --git a/protocol/v2/types/gloas/execution_requests.go b/protocol/v2/types/gloas/execution_requests.go new file mode 100644 index 0000000000..c49112ef02 --- /dev/null +++ b/protocol/v2/types/gloas/execution_requests.go @@ -0,0 +1,40 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. -path is this file: sszgen parses the builder requests and +// ExecutionRequests here and resolves the reused Electra request types (deposits/withdrawals/ +// consolidations) from the --include path. Includes track go-eth2-client via `go list -m`. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./execution_requests.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra --objs BuilderDepositRequest,BuilderExitRequest,ExecutionRequests" + +// BuilderDepositRequest is the EIP-8282 builder deposit request — a fixed-size container in the Gloas +// ExecutionRequests. +type BuilderDepositRequest struct { + Pubkey phase0.BLSPubKey `ssz-size:"48"` + WithdrawalCredentials [32]byte `ssz-size:"32"` + Amount phase0.Gwei + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// BuilderExitRequest is the EIP-8282 builder exit request — a fixed-size container in the Gloas +// ExecutionRequests. +type BuilderExitRequest struct { + SourceAddress bellatrix.ExecutionAddress `ssz-size:"20"` + Pubkey phase0.BLSPubKey `ssz-size:"48"` +} + +// ExecutionRequests is the Gloas execution requests: the Electra three (deposits, withdrawals, +// consolidations) plus the EIP-8282 builder deposit/exit requests. A Gloas CL encodes all five lists, so +// electra.ExecutionRequests (three) marshals a block two offsets short and the CL rejects the §4 submit as +// invalid SSZ — hence this node-side five-list variant. List bounds are the spec MAX_* values. +type ExecutionRequests struct { + Deposits []*electra.DepositRequest `ssz-max:"8192"` + Withdrawals []*electra.WithdrawalRequest `ssz-max:"16"` + Consolidations []*electra.ConsolidationRequest `ssz-max:"2"` + BuilderDeposits []*BuilderDepositRequest `ssz-max:"256"` + BuilderExits []*BuilderExitRequest `ssz-max:"16"` +} diff --git a/protocol/v2/types/gloas/execution_requests_encoding.go b/protocol/v2/types/gloas/execution_requests_encoding.go new file mode 100644 index 0000000000..dc99d8e010 --- /dev/null +++ b/protocol/v2/types/gloas/execution_requests_encoding.go @@ -0,0 +1,497 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 32e06494ddf67ab0d4d76fea6f6d60bd595f960195561d05190b96ae7f716834 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the BuilderDepositRequest object +func (b *BuilderDepositRequest) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(b) +} + +// MarshalSSZTo ssz marshals the BuilderDepositRequest object to a target array +func (b *BuilderDepositRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'Pubkey' + dst = append(dst, b.Pubkey[:]...) + + // Field (1) 'WithdrawalCredentials' + dst = append(dst, b.WithdrawalCredentials[:]...) + + // Field (2) 'Amount' + dst = ssz.MarshalUint64(dst, uint64(b.Amount)) + + // Field (3) 'Signature' + dst = append(dst, b.Signature[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the BuilderDepositRequest object +func (b *BuilderDepositRequest) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 184 { + return ssz.ErrSize + } + + // Field (0) 'Pubkey' + copy(b.Pubkey[:], buf[0:48]) + + // Field (1) 'WithdrawalCredentials' + copy(b.WithdrawalCredentials[:], buf[48:80]) + + // Field (2) 'Amount' + b.Amount = phase0.Gwei(ssz.UnmarshallUint64(buf[80:88])) + + // Field (3) 'Signature' + copy(b.Signature[:], buf[88:184]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the BuilderDepositRequest object +func (b *BuilderDepositRequest) SizeSSZ() (size int) { + size = 184 + return +} + +// HashTreeRoot ssz hashes the BuilderDepositRequest object +func (b *BuilderDepositRequest) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(b) +} + +// HashTreeRootWith ssz hashes the BuilderDepositRequest object with a hasher +func (b *BuilderDepositRequest) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Pubkey' + hh.PutBytes(b.Pubkey[:]) + + // Field (1) 'WithdrawalCredentials' + hh.PutBytes(b.WithdrawalCredentials[:]) + + // Field (2) 'Amount' + hh.PutUint64(uint64(b.Amount)) + + // Field (3) 'Signature' + hh.PutBytes(b.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the BuilderDepositRequest object +func (b *BuilderDepositRequest) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(b) +} + +// MarshalSSZ ssz marshals the BuilderExitRequest object +func (b *BuilderExitRequest) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(b) +} + +// MarshalSSZTo ssz marshals the BuilderExitRequest object to a target array +func (b *BuilderExitRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'SourceAddress' + dst = append(dst, b.SourceAddress[:]...) + + // Field (1) 'Pubkey' + dst = append(dst, b.Pubkey[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the BuilderExitRequest object +func (b *BuilderExitRequest) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 68 { + return ssz.ErrSize + } + + // Field (0) 'SourceAddress' + copy(b.SourceAddress[:], buf[0:20]) + + // Field (1) 'Pubkey' + copy(b.Pubkey[:], buf[20:68]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the BuilderExitRequest object +func (b *BuilderExitRequest) SizeSSZ() (size int) { + size = 68 + return +} + +// HashTreeRoot ssz hashes the BuilderExitRequest object +func (b *BuilderExitRequest) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(b) +} + +// HashTreeRootWith ssz hashes the BuilderExitRequest object with a hasher +func (b *BuilderExitRequest) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'SourceAddress' + hh.PutBytes(b.SourceAddress[:]) + + // Field (1) 'Pubkey' + hh.PutBytes(b.Pubkey[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the BuilderExitRequest object +func (b *BuilderExitRequest) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(b) +} + +// MarshalSSZ ssz marshals the ExecutionRequests object +func (e *ExecutionRequests) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(e) +} + +// MarshalSSZTo ssz marshals the ExecutionRequests object to a target array +func (e *ExecutionRequests) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(20) + + // Offset (0) 'Deposits' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.Deposits) * 192 + + // Offset (1) 'Withdrawals' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.Withdrawals) * 76 + + // Offset (2) 'Consolidations' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.Consolidations) * 116 + + // Offset (3) 'BuilderDeposits' + dst = ssz.WriteOffset(dst, offset) + offset += len(e.BuilderDeposits) * 184 + + // Offset (4) 'BuilderExits' + dst = ssz.WriteOffset(dst, offset) + + // Field (0) 'Deposits' + if size := len(e.Deposits); size > 8192 { + err = ssz.ErrListTooBigFn("ExecutionRequests.Deposits", size, 8192) + return + } + for ii := 0; ii < len(e.Deposits); ii++ { + if dst, err = e.Deposits[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (1) 'Withdrawals' + if size := len(e.Withdrawals); size > 16 { + err = ssz.ErrListTooBigFn("ExecutionRequests.Withdrawals", size, 16) + return + } + for ii := 0; ii < len(e.Withdrawals); ii++ { + if dst, err = e.Withdrawals[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (2) 'Consolidations' + if size := len(e.Consolidations); size > 2 { + err = ssz.ErrListTooBigFn("ExecutionRequests.Consolidations", size, 2) + return + } + for ii := 0; ii < len(e.Consolidations); ii++ { + if dst, err = e.Consolidations[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (3) 'BuilderDeposits' + if size := len(e.BuilderDeposits); size > 256 { + err = ssz.ErrListTooBigFn("ExecutionRequests.BuilderDeposits", size, 256) + return + } + for ii := 0; ii < len(e.BuilderDeposits); ii++ { + if dst, err = e.BuilderDeposits[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + // Field (4) 'BuilderExits' + if size := len(e.BuilderExits); size > 16 { + err = ssz.ErrListTooBigFn("ExecutionRequests.BuilderExits", size, 16) + return + } + for ii := 0; ii < len(e.BuilderExits); ii++ { + if dst, err = e.BuilderExits[ii].MarshalSSZTo(dst); err != nil { + return + } + } + + return +} + +// UnmarshalSSZ ssz unmarshals the ExecutionRequests object +func (e *ExecutionRequests) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 20 { + return ssz.ErrSize + } + + tail := buf + var o0, o1, o2, o3, o4 uint64 + + // Offset (0) 'Deposits' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 20 { + return ssz.ErrInvalidVariableOffset + } + + // Offset (1) 'Withdrawals' + if o1 = ssz.ReadOffset(buf[4:8]); o1 > size || o0 > o1 { + return ssz.ErrOffset + } + + // Offset (2) 'Consolidations' + if o2 = ssz.ReadOffset(buf[8:12]); o2 > size || o1 > o2 { + return ssz.ErrOffset + } + + // Offset (3) 'BuilderDeposits' + if o3 = ssz.ReadOffset(buf[12:16]); o3 > size || o2 > o3 { + return ssz.ErrOffset + } + + // Offset (4) 'BuilderExits' + if o4 = ssz.ReadOffset(buf[16:20]); o4 > size || o3 > o4 { + return ssz.ErrOffset + } + + // Field (0) 'Deposits' + { + buf = tail[o0:o1] + num, err := ssz.DivideInt2(len(buf), 192, 8192) + if err != nil { + return err + } + e.Deposits = make([]*electra.DepositRequest, num) + for ii := 0; ii < num; ii++ { + if e.Deposits[ii] == nil { + e.Deposits[ii] = new(electra.DepositRequest) + } + if err = e.Deposits[ii].UnmarshalSSZ(buf[ii*192 : (ii+1)*192]); err != nil { + return err + } + } + } + + // Field (1) 'Withdrawals' + { + buf = tail[o1:o2] + num, err := ssz.DivideInt2(len(buf), 76, 16) + if err != nil { + return err + } + e.Withdrawals = make([]*electra.WithdrawalRequest, num) + for ii := 0; ii < num; ii++ { + if e.Withdrawals[ii] == nil { + e.Withdrawals[ii] = new(electra.WithdrawalRequest) + } + if err = e.Withdrawals[ii].UnmarshalSSZ(buf[ii*76 : (ii+1)*76]); err != nil { + return err + } + } + } + + // Field (2) 'Consolidations' + { + buf = tail[o2:o3] + num, err := ssz.DivideInt2(len(buf), 116, 2) + if err != nil { + return err + } + e.Consolidations = make([]*electra.ConsolidationRequest, num) + for ii := 0; ii < num; ii++ { + if e.Consolidations[ii] == nil { + e.Consolidations[ii] = new(electra.ConsolidationRequest) + } + if err = e.Consolidations[ii].UnmarshalSSZ(buf[ii*116 : (ii+1)*116]); err != nil { + return err + } + } + } + + // Field (3) 'BuilderDeposits' + { + buf = tail[o3:o4] + num, err := ssz.DivideInt2(len(buf), 184, 256) + if err != nil { + return err + } + e.BuilderDeposits = make([]*BuilderDepositRequest, num) + for ii := 0; ii < num; ii++ { + if e.BuilderDeposits[ii] == nil { + e.BuilderDeposits[ii] = new(BuilderDepositRequest) + } + if err = e.BuilderDeposits[ii].UnmarshalSSZ(buf[ii*184 : (ii+1)*184]); err != nil { + return err + } + } + } + + // Field (4) 'BuilderExits' + { + buf = tail[o4:] + num, err := ssz.DivideInt2(len(buf), 68, 16) + if err != nil { + return err + } + e.BuilderExits = make([]*BuilderExitRequest, num) + for ii := 0; ii < num; ii++ { + if e.BuilderExits[ii] == nil { + e.BuilderExits[ii] = new(BuilderExitRequest) + } + if err = e.BuilderExits[ii].UnmarshalSSZ(buf[ii*68 : (ii+1)*68]); err != nil { + return err + } + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the ExecutionRequests object +func (e *ExecutionRequests) SizeSSZ() (size int) { + size = 20 + + // Field (0) 'Deposits' + size += len(e.Deposits) * 192 + + // Field (1) 'Withdrawals' + size += len(e.Withdrawals) * 76 + + // Field (2) 'Consolidations' + size += len(e.Consolidations) * 116 + + // Field (3) 'BuilderDeposits' + size += len(e.BuilderDeposits) * 184 + + // Field (4) 'BuilderExits' + size += len(e.BuilderExits) * 68 + + return +} + +// HashTreeRoot ssz hashes the ExecutionRequests object +func (e *ExecutionRequests) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(e) +} + +// HashTreeRootWith ssz hashes the ExecutionRequests object with a hasher +func (e *ExecutionRequests) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Deposits' + { + subIndx := hh.Index() + num := uint64(len(e.Deposits)) + if num > 8192 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.Deposits { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 8192) + } + + // Field (1) 'Withdrawals' + { + subIndx := hh.Index() + num := uint64(len(e.Withdrawals)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.Withdrawals { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + // Field (2) 'Consolidations' + { + subIndx := hh.Index() + num := uint64(len(e.Consolidations)) + if num > 2 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.Consolidations { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 2) + } + + // Field (3) 'BuilderDeposits' + { + subIndx := hh.Index() + num := uint64(len(e.BuilderDeposits)) + if num > 256 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.BuilderDeposits { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 256) + } + + // Field (4) 'BuilderExits' + { + subIndx := hh.Index() + num := uint64(len(e.BuilderExits)) + if num > 16 { + err = ssz.ErrIncorrectListSize + return + } + for _, elem := range e.BuilderExits { + if err = elem.HashTreeRootWith(hh); err != nil { + return + } + } + hh.MerkleizeWithMixin(subIndx, num, 16) + } + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the ExecutionRequests object +func (e *ExecutionRequests) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(e) +} diff --git a/protocol/v2/types/gloas/testdata/devnet6_gloas_block.ssz b/protocol/v2/types/gloas/testdata/devnet6_gloas_block.ssz new file mode 100644 index 0000000000000000000000000000000000000000..e6018790a1e0c421cf1fcf6383be88fbbf30731f GIT binary patch literal 1367 zcmYdcU|{Gw_=fr2Hg~mYi|=1!xg35%P&i|O<3jDyZQK66%{1E(&2-0PU0<(uaMP$MRT<4JB6oY0wB!mm&f z(kY;^-sDkT9MmfeI~zWyS>0Lv-gnAD;jF)3PoF)b`|n4rVPWZ=ly^ri?lyIIHL*-J z^zkq@OEQP)L8f~c85m%=mWhD@gzNu*{{Q;_KKpdmCi#Ir7^np>&z$Kl7|P*;C!?HDijppVxn1 zTGEsNg?iNf4@kePv@n}vY^h#1}gu_Lm_fNVnXjHVnX7Z@a9(5ZLhwNqtL*v6pF z3HvUonJ{);-eNTG>4w?8-&1WSzn|D7@#ygK@V-`+CCc}wH0p6qJ@d0-mSpS&<_@uU zZzlXNZR)5oRC?L@nNRTly^i^ATX^+@?#IRJ#h!1RyyrpEr#q^PuY1>h=a|#(cssPe z)uB|()5*K__@v~$a_XP#7gq=YqY4aIQ-IU~2%Bc)?v?!q0^f3!ZMX8hQ@#9a%&gRk zQho!MrA=ljo1c}w+e0XDXXzaL`EF(Pv~xzu-F9m&jdc$CFt2!ffb9X-i?-CY-!zNf z3hdCScv)MOeeG!EpIef7Pd#levrT@gCT6%a;pl@ztR8Eh42*IHhW}6i%h>2@9{>fc znBFq4w%7Xpo-gm=5@$8{xmp`9eJ~TgD|cX_{CV^B(_n##u9sLoA^*b?BO~La1Ha0G zynWp&E!)C1-ye%}G@aw$+4B0wjjV*q|E B1uOsn literal 0 HcmV?d00001 diff --git a/protocol/v2/types/gloas/testing.go b/protocol/v2/types/gloas/testing.go index b29e685961..4cc720cf6f 100644 --- a/protocol/v2/types/gloas/testing.go +++ b/protocol/v2/types/gloas/testing.go @@ -2,7 +2,6 @@ package gloas import ( "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" bitfield "github.com/prysmaticlabs/go-bitfield" ) @@ -16,7 +15,7 @@ func TestingBeaconBlock(slot phase0.Slot) *BeaconBlock { ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, SignedExecutionPayloadBid: &SignedExecutionPayloadBid{Message: &ExecutionPayloadBid{BuilderIndex: BuilderIndexSelfBuild}}, - ParentExecutionRequests: &electra.ExecutionRequests{}, + ParentExecutionRequests: &ExecutionRequests{}, }, } } From 68882264146274d5a5f650a3478da27cbd3350d2 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 12:31:31 +0300 Subject: [PATCH 095/150] add tla/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 97326515f4..da0e1d1895 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ bin/ /scripts/spec_align_report/ssv-spec .vscode/ patches/ +tla/ go.work go.work.sum From 1b242681e5943dc94aefe4619b9262d2e08fa8c7 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 13:10:12 +0300 Subject: [PATCH 096/150] gloas: remove the ePBS implementation plan (fully migrated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporary in-branch scratch doc required removal once #2901 was in review; its still-open content now lives in durable homes — the devnet e2e runbook and verify items in #2920, the follow-ups and known limitations in the #2901 description, and the MEV-timing-games decision on #2855. The research/decision history stays retrievable from this branch's log. --- EPBS_IMPLEMENTATION_PLAN.md | 596 ------------------------------------ 1 file changed, 596 deletions(-) delete mode 100644 EPBS_IMPLEMENTATION_PLAN.md diff --git a/EPBS_IMPLEMENTATION_PLAN.md b/EPBS_IMPLEMENTATION_PLAN.md deleted file mode 100644 index ed538546f8..0000000000 --- a/EPBS_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,596 +0,0 @@ -# ePBS (EIP-7732 / Gloas) — ssv node implementation plan - -**Working/ephemeral document.** Scratch space for planning the node-side implementation of [SIP ssvlabs/SIPs#94](https://github.com/ssvlabs/SIPs/pull/94). Delete once the work lands. Do not reference from code, comments, or other docs. - -> **⚠️ ACTION — delete this file before [#2901](https://github.com/ssvlabs/ssv/pull/2901) is marked ready for review.** It is committed (rather than kept local) only as a temporary shared home for in-flight ePBS context. Before flipping #2901 to *ready for review*, move **all** still-useful / unfinished action items (e.g. the devnet e2e steps, the devnet-verify items, the upstream-gated follow-ups, the MEV_CONSIDERATIONS.md rewrite, the ProposerPreferences publish-finality follow-up) into the PR description, then remove this file in the same PR. Nothing here should outlive the PR. - -**Status:** **PTC slice implemented node-side + committed; P1 image built; e2e staged for the live devnet (now devnet-6); PTC code review addressed; rebased onto the refreshed `boole-fork`** (see §6/§7) — §1 timing, §2 committee, §4 proposer (T7), and §5 ProposerPreferences are now done & committed node-side; §6 envelope (T8) is functionally complete node-side — the envelope types, value-check, decided-root store, proposer-side self-build trigger, EnvelopeBuilder runner, heavy payload, post-consensus e2e tests, and `ExecutionPayload` HTR-parity verification are all done; only the stateless `…Contents` blob-carrying publish body (optional; §6 publishes the blinded/stateful body) and a computational spec-vector cross-check (once Gloas ships) remain (see T8). **Update 2026-06-30: beacon-APIs#580 merged — §4 (v4 produce), §5 (proposer-preferences POST), §6 (blinded envelope publish) are now wired to the real merged endpoints (see the §7 update); go-eth2-client Gloas dedup still pending.** Research complete — the former Phase-0 investigations (§2) now carry their answers, so implementation is executable. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (SIP #94 maintainer call, 2026-06-23 — see U1). The items left in §2b are upstream API churn incl. go-eth2-client Gloas + runtime metrics + a couple of end-of-execution reconciliations. Gloas ships in **Glamsterdam, targeted ~Q3 2026** (slipped from June 2026 after the Soldøgn interop devnet); public testnets pending, so we build against devnet specs. - -**Baseline — the Boole fork (`boole-fork` is canonical).** ePBS builds on the SSV **Boole** protocol fork (successor to Alan): ssv-spec bumped `v1.2.2 → v1.2.3-pseudo`; `RoleAggregatorCommittee=6` with deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps; `SSVForks{ Boole }` + transition-window machinery; proposer round-robin; `lowestHash` topic→subnets. **Boole already shipped slices of this plan:** the node-side switches ePBS planned now exist — `protocol/v2/types/runner_role.go` (`RunnerRoleForValidatorDuty(duty, isBooleFork)`, fork-aware) and `protocol/v2/types/consensus_data.go` (version-switched extraction over `spectypes.ProposerConsensusData`). So **T7/T10 extend those, they don't author wrappers.** **`boole-fork` has not yet landed on stage** (stage is still `ssv-spec v1.2.2` / `SSVForks struct{}`; `boole-fork` is ~46 commits behind stage, tip Apr 2026) — but it's **expected to merge within ~2-3 weeks (≈ mid-July 2026), treated as ground truth** (see §2b). The build baseline is **`boole-fork`** (verify anchors against it). **Decision: ePBS starts now off `boole-fork`** — it must begin immediately for independent development + testing (against T2 mocks/devnet, which doesn't gate on Boole landing), so it can't wait for the merge; it rebases onto stage when Boole lands (~2-3 weeks) — see §6. The pre-Boole HEAD pin (`82a9f4f8f`) and §0/U findings are pre-Boole; corrections are inline where they flip (U0/U5/T7/T10/T11). - -**Scope:** the `ssv` node (this repo). All new protocol types are added **node-side** — aligned with the team decision to **migrate off ssv-spec imports entirely** (end-state). ePBS adds its types node-side and must not deepen ssv-spec coupling, but does **not** execute the full migration (separate initiative — see U0). **Deviation (PTC, as built):** the ePBS wire **constants** (roles/domains/partial-sig types) were added to **ssv-spec via [PR #632](https://github.com/ssvlabs/ssv-spec/pull/632)** — the established `spectypes` pattern, and required because ekm/ssvsigner reaches signing domains only through `spectypes` (a separate module that can't import node-side gloas); the node-side `protocol/v2/types/gloas` package holds only the **wire types**. The SIP remains the canonical wire source. Upstream-unmerged beacon APIs are **abstracted behind interface methods and mocked now**, swapped when upstream stabilizes. - -**Source pins (re-verify at implementation start — the spec is still pre-final):** -- Consensus specs: `ethereum/consensus-specs@6ebb2216c` (Gloas) -- The SIP's own text: [ssvlabs/SIPs#94](https://github.com/ssvlabs/SIPs/pull/94) -- Beacon APIs: produceBlockV4 + envelope endpoints in [beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) (open, head `bed49d98`); PTC + payload-attestation endpoints already tagged in `beacon-APIs@v5.0.0-alpha.2` - -**Non-goals / out of scope:** -- Anchor (Rust client) — coordinated separately; wire-level constants must match (see U0). -- Retiring `BeaconVote` / renaming `GloasBeaconVote` → `BeaconVote` — follow-up SIP, post-fork. -- The AggregatorCommittee consolidation refactor — **shipped by the Boole fork** (`boole-fork`), not ePBS; ePBS builds on the already-consolidated baseline (`RoleAggregatorCommittee=6`). See U0. -- The **full migration off ssv-spec imports** — a separate, larger initiative (main cost: spectest decoupling). ePBS only lays node-side types consistent with it; it does not execute the migration (see U0). -- The on-chain/EL side of ePBS. - ---- - -## 0. Verified facts (settled inputs) - -Confirmed against the pinned specs and the working tree (HEAD `82a9f4f8f`). Treat as inputs, not open questions. *(Spec claims below re-verified against `consensus-specs@6ebb2216c`, SIP #94, and beacon-APIs#580 on 2026-06-18 — notes inline; re-verify again at T1 start, the spec is pre-final.)* - -**Spec correctness (against `consensus-specs@6ebb2216c`):** -- §1 timing (attest/sync 25%, agg/contrib 50%, PTC 75%); §2 `index` semantics (same-slot⇒0, else 0=EMPTY/1=FULL); §3 `PayloadAttestationData{beacon_block_root, slot, payload_present, blob_data_available}`; domains `0x0B`/`0x0C`/`0x0D`; §5 `ProposerPreferences{dependent_root, proposal_slot, validator_index, fee_recipient, target_gas_limit}` and `get_upcoming_proposal_slots` (current epoch → `MIN_SEED_LOOKAHEAD` ahead). -- **§6 HTR equivalence holds**: real `ExecutionPayloadEnvelope` = `{payload, execution_requests, builder_index, beacon_block_root, parent_beacon_block_root}`; the blinded form mirrors it with only `payload`→`payload_root`. -- **§6 envelope is signed by the proposer's validator key** (`verify_execution_payload_envelope_signature` in `gloas/fork-choice.md`: self-build is flagged by the sentinel `builder_index == BUILDER_INDEX_SELF_BUILD` = `UINT64_MAX`, and the verifying key is then `state.validators[state.latest_block_header.proposer_index].pubkey` under `DOMAIN_BEACON_BUILDER`) — an ordinary BLS verify against the validator key, so SSV distributed BLS-share signing is correct. (Note: `builder_index` is *not* the validator index in self-build; the non-self-build branch reads a separate `state.builders[...]` registry.) - -**Node-side facts (resolved during research):** -- **No SSV `DomainType` bump needed for Gloas** (conclusion holds; pre-Boole basis corrected). Gloas is a **beacon** fork — gate it by beacon epoch via `Beacon.BeaconForkAtEpoch` (Boole's rename of `ForkAtEpoch`; see U5). The pre-Boole basis ("`SSVForks` is an empty struct") is now **false**: Boole made `SSVForks{ Boole phase0.Epoch }` and added a `DomainType`/`NextDomainType` rotation (`networkconfig/ssv.go`). But those are **SSV-fork** (Boole) concerns, distinct from beacon forks, and the Gloas signing domains (`0x0B/0C/0D`) are beacon-side — so the SSV `DomainType` is untouched. Open at T1: whether Gloas also wants an `SSVForks` entry now that the machinery exists, or stays purely beacon-epoch-gated (default: beacon-epoch — it's beacon-driven). -- **Slashing protection needs no change.** Double-vote/surround detection compares **only `Source.Epoch`/`Target.Epoch`** — eth2-key-manager's `NormalProtection.IsSlashableAttestation` (reached via `ssvsigner/ekm/slashing_protector.go`) never inspects `index`, and by design doesn't even store signing roots. The full `AttestationData` is SSZ-persisted (`ssvsigner/ekm/signer_storage.go`), but only the source/target epochs are ever read for the slashable check — so the Gloas `index` semantics change is irrelevant to slashing, not because `index` is now part of the compared data but because it never was. The only index-aware work is in the value-check construction (T4). -- **A second concurrent QBFT instance for the same (validator, slot) is cleanly supported.** The two roles stay separate: message-validation consensus state and validation locks are keyed by `MessageID` (which includes `RunnerRole`), and runner queues by `RunnerRole` — so EnvelopeProposer (§6 QBFT variant) won't collide with the proposer's block QBFT. **Caveat:** this isolation holds on the per-validator runner path (`protocol/v2/ssv/validator/validator.go`, role-keyed `Queues`); the Committee path keys queues by **slot** (`committee.go`), not role. The three new roles must therefore be wired as per-validator runners, never onto the committee path (see T10). -- **The shared Gloas vote is inert for sync-committee duties.** The committee runner's sync path reads only `BeaconVote.BlockRoot` (`committee.go` sync-message construction); adding `AttestationDataIndex` to the vote doesn't touch it. -- **`api.VersionedProposal.Blinded`** is the local-vs-builder signal (already logged via `BeaconBlockIsBlindedAttribute`) — the basis for the U6 local-build metric. - -**Dependency landscape (resolved):** -- **go-eth2-client has no Gloas types** — neither the SSV fork (`v0.6.31-…`, based on upstream v0.27.0) nor upstream master (`v0.28.1`). No `DataVersionGloas`, no `spec/gloas` package. → the fork must be patched (U2). -- **ssv-spec has no Gloas work** (no branch/PR). The **AggregatorCommittee consolidation is now the baseline** — Boole bumps the node to `ssv-spec v1.2.3-pseudo` (`RoleAggregatorCommittee=6`; deprecated `RoleAggregator=1`/`RoleSyncCommitteeContribution=3` gaps), so the pre-Boole "`v1.2.2` contiguous/pre-consolidation" framing no longer applies. → U0. - ---- - -## 1. How to use this plan - -- **The investigations (§2) are resolved** — their decisions are baked into the tracks. Implementation can start. **U1 (§6 QBFT vs no-QBFT) is now resolved → QBFT** (see U1). The only gate left in **§2b** is upstream API maturity (mock + watch). -- Tracks (§3) are dependency-ordered; graph in §4. -- Symbol names/anchors were verified against **pre-Boole** HEAD `82a9f4f8f`; the Boole baseline (`boole-fork`) changes some of them — often small diffs, and some seams already exist on stage (e.g. the `GLOAS_FORK_EPOCH` TODO at `spec.go:255`) — so **re-verify against `boole-fork`** at implementation start. Line numbers are approximate (`~`) and may drift. -- Each new runner role touches five seams: **(a)** type/enum, **(b)** runner impl, **(c)** `SetupRunners` registration, **(d)** duty handler/trigger, **(e)** message validation. -- **Observability is logs-first (see §8).** Every test/verification claim in this plan must be expressed as a **greppable DEBUG log-line** — so all planned testing is automatable (grep the line, assert it). OTel metrics (U6/T13) are explicitly **nice-to-have** (dashboards / aggregation / visualization only) and are **never** a primary validation tool. - ---- - -## 1b. Revisit-later TODOs — consolidated index - -The single canonical list of "revisit at a later date" items; detail lives at the `§`/file pointers. **New TODOs land here**, not sprinkled inline. Migrate this list into the #2901 description when this doc is removed (per the top-of-file note). Verify each against the code before acting — some inline notes may have closed since. - -**Devnet run 2026-07-01 — §4 produces on devnet-5; active frontier (detail in §7)** -- [x] **Gloas local block slashing protection** — ✅ DONE: `SignBeaconObject` plumbs the slot through; the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing — closes the `3c21d06fa` TODO. (§7 devnet-run) -- [ ] **devnet-6 SSZ drift** — `could not submit gloas beacon block: invalid SSZ`; the node-side Gloas block SSZ diverges from devnet-6's spec. Find the field diff. (§7 devnet-run; consensus-specs pin drift) -- [ ] **§6 envelope produce 404** — `GetExecutionPayloadEnvelope` 404s even where §4 landed; needs devnet correlation (exact path/body/block-landed) — can't fix node-side blind. (§7 devnet-run) -- [x] **§2 attestation Index rejection** — ✅ DONE: go-eth2-client's post-Electra `data.Index==0` check rejected the Gloas payload-status `Index=1` (FULL — healthy case), failing attestations; fixed via a hand-rolled Gloas `attestation_data` fetch that skips the check. NOT transient. (§7 devnet-run) - -**On the first devnet run / verification** -- [ ] **§2 Fulu-tag attestation** — confirm a Gloas BN accepts the Fulu-tagged attestation submission on Gloas slots; if rejected, extend `BeaconForkAtEpoch` → `DataVersionGloas` (the `TODO(gloas)` in `networkconfig/beacon.go`). *High if it fails — every attestation would.* (T4, ~line 168) -- [ ] **§4/§6 stateless Contents** — confirm the §6 blinded-vs-`Contents` body choice; wire `SignedExecutionPayloadEnvelopeContents` (envelope + blobs + KZG) only if a devnet BN runs payload-stateless (also un-defers T7's blob plumbing). (§7 "Remaining"; finding #2) -- [ ] **QuickTimeout** — RTT-tune the 2s round budget / decide on restoring the round-2 proposer fallback from devnet data. (T3 note; §7 timing audit) -- [ ] **Telemetry on devnet** — add the G5 PTC-non-convergence log once gauged; revisit §6 priority + any no-QBFT tuning against real local-build / recon-miss rates. (§8; §2b) -- [ ] **devnet network stubs** — fill `RegistryContractAddr`, `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs). (§7 checklist step 5) -- [ ] **ssv-mini monitor** — flip `monitor.enabled: true` in `params-gloas.yaml` once ethereum2-monitor#504 is in the image. (§7 local_testnet step 3) - -**On the SIP-94 §5 decision (cross-client — blocked)** -- [ ] **§5 re-emission** — validation: dedup `ProposerPreferencesPartialSig` by `(slot, signer, root)` up to a bound N (proposed 4); handler: re-emit only on a real `dependent_root` change (needs per-slot root tracking). Must land in SIP-94 §5 + be matched by Anchor. (§7 DEFERRED; finding #1) -- [ ] **§5 publish-finality** — hold publication until `dependent_root`/`fee_recipient`/`target_gas_limit` are final. `KNOWN ISSUE` comment now in `buildProposerPreferences`; implement the hold only if it bites on devnet. (finding #3) - -**Remote signing (Web3Signer) on Gloas — broken for all duties (detail + fixes in §7)** -- [x] **`fork_info` Gloas version (RS-1)** — ✅ DONE: scoped Gloas fork in `GetForkInfo` via the existing `ForkAtVersion` (ssvsigner-local Gloas data version) — unblocks all existing remote duties. (§7 Remote-signing) -- [ ] **§4 block signing (RS-2)** — **DEFERRED (module boundary):** ssvsigner can't name `gloas.BeaconBlock`, so pass the block header (`phase0.BeaconBlockHeader`) across instead (+RS-1); cross-system-gated, bigger than RS-1. (§7 Remote-signing) -- [ ] **§3/§5/§6 Web3Signer types (RS-3)** — upstream-blocked (Web3Signer must add payload-attestation / proposer-preferences / envelope types); local-sign meanwhile, bounded by `f`. (§7 Remote-signing) - -**On upstream / cross-client maturity** -- [ ] **go-eth2-client Gloas** — swap the hand-rolled types/HTTP for upstream `spec/gloas` when it ships (a dedup, not a gate). (§2b; U2) -- [ ] **Anchor §5/§6 constants** — re-check wire constants vs sigp/anchor once it builds §5/§6 (PTC already verified). (§2b; §4 seq) -- [ ] **consensus-specs pin** — re-verify at each milestone (SIP watchlist tracks normative drift). (§2b) -- [ ] **HTR spec vectors** — run the computational Gloas SSZ cross-check against canonical vectors once the fork ships (none exist yet). (T8) -- [ ] **SIP-94 §4/§6 text** — reconcile the SIP to the merged beacon-APIs#580 flow (`include_payload=false` + blinded) that the impl tracks. (finding #2) -- [ ] **EIP-8282** — add node-side Gloas `ExecutionRequests` (builder deposit/exit) + HTR-parity vectors if/when a target devnet adopts it. (T8 review) - -**On Boole → stage landing** -- [ ] `git rebase --onto stage epbs-gloas` to move the ePBS commits when Boole merges. (§6) - -**Investigated & closed — do not revisit** -- **§2 slashing-index** — the Gloas payload-status index passed to `IsAttestationSlashable` is inert (SSV's slashing protection is epoch-only; verified in eth2-key-manager). No action. (finding #4) - ---- - -## 2. Resolved investigations (findings + decisions) - -### U0 — How the new protocol types enter the node **(decided — incl. the ssv-spec posture)** -**Findings:** -- The new wire constants (roles, partial-sig types, domains, `GloasBeaconVote`, `EnvelopeConsensusData`, `BlindedExecutionPayloadEnvelope`) **do not exist in canonical ssv-spec** — there's no Gloas branch/PR. SSV must author them regardless of bump-vs-copy. -- ssv-spec's AggregatorCommittee consolidation is now **the baseline** — it lands in stage via the **Boole** fork (`ssv-spec v1.2.3-pseudo`). ePBS builds **on top of** the consolidated roles; the old "decouple an unreleased refactor" framing is obsolete. -- Wire values are protocol-canonical and must match Anchor; the **SIP (not an ssv-spec PR) is the canonical wire source**. - -**Decision/recommendation:** -- **The consolidation is the Boole baseline, not an ePBS prerequisite** — ePBS builds on it (shipped via `boole-fork`); it never owns or bumps it. -- **Add the Gloas types in-tree now** (e.g. a `gloastypes` package), using the SIP's canonical values: `RunnerRole` PTC=7/Prefs=8/Envelope=9; `BeaconRole` 7/8/9; `PartialSigMsgType` `PTCAttesterPartialSig`=7, `ProposerPreferencesPartialSig`=8 (+ `EnvelopePartialSig`=9 only if U1 picks no-QBFT); domains `0x0B/0x0C/0x0D`. These slot cleanly above the now-baseline consolidated max (`RoleAggregatorCommittee=6`), verified against post-Boole `ssv-spec v1.2.3-pseudo`. (Wire values verified against SIP #94: roles 7/8/9, partial-sigs 7/8, domains `DomainBeaconBuilder=0x0B`/`DomainPTCAttester=0x0C`/`DomainProposerPreferences=0x0D`; the SIP reserves RunnerRole 1/3 for pre-consolidation back-compat decoding.) -- **Extend Boole's existing node-side switches — don't author new wrappers.** Boole already moved both switches node-side (for its own consolidation back-compat): `protocol/v2/types/consensus_data.go` version-switches extraction over `spectypes.ProposerConsensusData` (the `getBlockData` seam), and `protocol/v2/types/runner_role.go`'s `RunnerRoleForValidatorDuty(duty, isBooleFork)` is the fork-aware duty→role map (replacing `spectypes.MapDutyToRunnerRole` node-side). So T7 adds a `DataVersionGloas` arm to `consensus_data.go`, and T10 adds the three new roles + an `isGloas` branch to `RunnerRoleForValidatorDuty` (mirroring its existing `isBooleFork` shape). The "wrappers" are extension points already in-tree — and already the migration seams. **Parameterizes T7, T10.** -- **Lock the Anchor wire constants early, not at end-of-execution.** They're already in the SIP (roles 7/8/9, partial-sigs 7/8, domains `0x0B/0C/0D`) and isolated — so confirming them with Anchor is *cheap now* and *expensive to discover wrong at interop*. Isolation makes them cheap to lock early, not a reason to defer. Proceed with the SIP-canonical values and get Anchor's explicit ack up front (against the SIP — the canonical wire source, no ssv-spec PR) rather than deferring to the end (see §2b). - -**ssv-spec posture (decided): migrate off ssv-spec imports entirely** (end-state). ePBS runs in the pragmatic fallback: **define the new types we need node-side; never modify, bump, or PR ssv-spec.** Guiding principle for every track — *do not deepen ssv-spec coupling*: new types are node-side; existing ssv-spec types are extended (node-side constants of the existing type) or wrapped, never edited; any node-side type that mirrors an ssv-spec one stays **SSZ/wire-identical** through the hybrid phase. The consolidation shipped via Boole's ssv-spec bump (`v1.2.3`) and is the baseline; ePBS neither bumps nor owns it. **Parameterizes T1, T7, T10, and all role work.** - -**Relationship to the full migration:** owning *all* duty-related types node-side (`ValidatorConsensusData`/`BeaconVote`/duty/role/value-check, `MessageID`, the QBFT types, …) is a **separate, larger initiative** — out of scope here. ePBS is its **first down-payment**: the Gloas types land node-side, and the wrappers (T7/T10) are the seams the migration will later cut. That migration's main cost is **spectest decoupling** (the node runs ssv-spec test vectors) — but ePBS doesn't pay it: Gloas has no ssv-spec vectors, so the new node-side types don't disturb existing spectest compatibility. Forward-compat: the Gloas role/partial-sig constants are values of the *existing* `spectypes` base types for now, and move wholesale when those base types are reimplemented node-side. Note: nothing in ePBS *forces* an ssv-spec edit — QBFT is value-agnostic (a new instance over `BlindedExecutionPayloadEnvelope` needs only a node-side value-check), and `MessageID` is reused read-only with node-side role values. **Steady-state caveat (largely moot post-Boole):** the duty→role / consensus-data switches T7/T10 extend are **already in-tree as Boole's** (`runner_role.go`/`consensus_data.go`), serving Boole's own consolidation back-compat — so they're load-bearing regardless of whether the ssv-spec migration ever runs. Only `gloastypes` is genuinely ePBS-introduced, and it's a clean node package fine to live with permanently. - -### U1 — §6 envelope distribution: QBFT vs no-QBFT(sign-all) **(RESOLVED — QBFT; SIP #94 maintainer call, 2026-06-23)** -**Decision: QBFT** — keep the SIP's prescribed shape (a second QBFT round over the blinded envelope; **no** new `PartialSigMsgType` — post-consensus reuses `PostConsensusPartialSig`, role discriminates routing). GalRogozinski on the §6 thread ([r3460315536](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3460315536)): *"QBFT is the correct call for now. It handles the faulty leader edge case. The way forward is for later SIPs to reduce the QBFT timeout. Eventually we need a more suitable consensus algo."* This **overrules** the earlier node-side recommendation (no-QBFT sign-all). What carried it (shane-moore's surface argument, [r3364710775](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3364710775)): the no-QBFT path's real cost is a **new top-level network message class** — a dissemination carrier for the `BlindedExecutionPayloadEnvelope` plus a new `EnvelopePartialSig` kind — touching the message-validation dispatch, a new validated-message variant, receiver routing, and byte-compat across every client (go-ssv, Anchor, future). QBFT reuses existing consensus machinery with **no new message class** and the default round-robin leader; its degeneracy (a round-changed envelope leader ≠ block proposer) is mild given the ~6s budget from the 25% block deadline to the 75% payload-due cutoff. The latency/Byzantine edge for no-QBFT was judged not to outweigh that surface for a path expected to be rare (local-build ~0.1–2%). **Contained to T8 (ships last).** T8 builds the QBFT variant only; the no-QBFT design is retired (kept for history). - -### U2 — go-eth2-client Gloas support + beacon API maturity **(decided)** -**Findings:** -- Gloas is absent from both the SSV fork and upstream — **there is nothing to bump to**. -- produceBlockV4 confirmed: response is `anyOf [Gloas.BeaconBlock, Gloas.BlockContents]` (per #580 head — `anyOf`, not `oneOf`; no blinded-block variant post-Gloas), discriminated by the `Eth-Execution-Payload-Included` header (+ `Eth-Consensus-Version: gloas`); SSZ or JSON. Envelope POST (`publishExecutionPayloadEnvelope`) accepts `SignedExecutionPayloadEnvelopeContents` (stateless) **or** bare `SignedExecutionPayloadEnvelope` (stateful); envelope GET returns `Gloas.ExecutionPayloadEnvelope`. -- **Endpoint maturity tiers:** - - **Merged/tagged** (lower risk): PTC duties, `payload_attestation_data`, `payload_attestations` pool — in `beacon-APIs@v5.0.0-alpha.2` and master. - - **Unmerged** (pin to #580, expect churn): produceBlockV4, envelope POST/GET, `types/gloas/{block_contents,execution_payload_envelope}`. - - **Nonexistent** (fully abstract, no real BN to test against): validator-facing `SignedProposerPreferences` publication. - -**Decision (revised — full node-side implementation; supersedes the earlier mock-only stance):** build the Gloas beacon surface node-side **now** — full-fidelity SSZ/JSON types (BeaconBlock/BlockContents, ExecutionPayloadEnvelope + Contents, PayloadAttestation/Data/Message + PTCDuty, ProposerPreferences/Signed) **and** hand-rolled HTTP endpoint clients — not abstract-and-mock-only. Rationale: a multi-client Glamsterdam/Gloas devnet is **live** (since ~May 2026), so a *real* implementation can be e2e-tested against a local devnet (see T12 / ssv-mini); mock-only can't be. Mocks are kept, but only for unit tests. The go-eth2-client rebase is now a **later dedup** — swap our node-side types/clients for upstream's `spec/gloas` when it ships — **not** a prerequisite or a gate. Types/endpoints are `#580`-pinned and will churn → watch + iterate. (`SignedProposerPreferences` is the lone exception: no endpoint exists in any client, so it stays mock-only — see §2b.) **Parameterizes T2.** - -### U3 — `BeaconNode` interface diff + mocks **(decided)** -Add to `protocol/v2/blockchain/beacon/client.go` (mocks regen via `//go:generate mockgen` at `client.go:~15`): -- `PTCCalls`: `PTCDuties(epoch)`, `PayloadAttestationData(slot)`, `SubmitPayloadAttestations(...)` — merged endpoints. -- `ProposerPreferencesCalls`: `SubmitProposerPreferences(...)` — **no real endpoint yet; mock only**. Supersedes `ValidatorRegistrationCalls` post-fork. -- `ExecutionPayloadEnvelopeCalls`: `GetExecutionPayloadEnvelope(slot, beaconBlockRoot)`, `SubmitExecutionPayloadEnvelope(contents | bare)` — #580. -- produceBlockV4 path on `ProposerCalls.GetBeaconBlock` (version + `Eth-Execution-Payload-Included`-aware). **Parameterizes T2.** - -### U4 — Message-validation model + ProposerPreferences carried-slot **(decided; impl in T9)** -**Findings/decisions:** -- Per-role allowances (`partialSignatureTypeMatchesRole`, `validRole`): PTC + ProposerPreferences are **partial-sig only** (reject consensus messages, like ValidatorRegistration); EnvelopeProposer is **QBFT + post-consensus**. -- **Carried slot (corrected — supersedes the earlier emission-slot sketch):** the base runner ties **three** checks to a single `DutySlot` — `validatePartialSigMsg` (receiver requires `msg.Slot == DutySlot`), `verifyExpectedRoot` (signing-domain epoch from `DutySlot`), and network `messageEarliness`. The wire signature is under `epoch(proposal_slot)`, so `DutySlot = proposal_slot` is **forced**, hence `msg.Slot = proposal_slot` (an emission-slot override would make every receiver drop the message as "slot already passed"). The future `proposal_slot` is instead permitted by a **bounded role-specific earliness allowance** in `messageEarliness` (T9) — not by runner slot-trickery. -- **Pre-fork acceptance:** message validation must accept `RoleProposerPreferences` for `~MIN_SEED_LOOKAHEAD` epoch(s) before `GLOAS_FORK_EPOCH` (fork-aware allow). This is net-new logic — there is **no existing ValidatorRegistration fork-cutoff in message validation to mirror**; both this pre-fork accept and the VR post-fork reject (T5) are new and must be built symmetrically. The gate to design around is `messageEarliness` (role-agnostic, no per-role exemption), which would otherwise reject a future-slot message. -- `dutyLimit` (`common_checks.go:~117`, whose `default` returns `(0, false)` — an *exists* flag the caller uses to skip the limit, not a numeric no-limit) and `messageLateness` (no `default` → unlisted role treated late just past slot-start) both need explicit arms for the three roles (`dutyLimit` arms returning `(limit, true)`). Note `validRole`/`partialSignatureTypeMatchesRole` **already** have arms for the non-QBFT roles (ValidatorRegistration/VoluntaryExit) — a clean template to copy; only `maxRound` (default→error) and `dutyLimit` (default→`(0,false)`) truly lack arms. -**Parameterizes T5, T6, T9.** - -### U5 — Fork-gating + slashing **(decided)** -**Findings/decisions:** -- **Gating:** beacon fork epochs are fetched at runtime from the BN's `/eth/v1/config/spec` (not node config). Wire Gloas by: adding `DataVersionGloas` (via the U2 fork patch), filling the `GLOAS_FORK_EPOCH` TODO in `beacon/goclient/spec.go` (**already present on stage at `spec.go:255`**), adding Gloas to the beacon fork-epoch method, and a node `IsGloas(slot/epoch)` helper. No per-network config-file changes. **Boole-baseline note (corrected):** `spec.go`/`networkconfig/beacon.go` are **not** rewritten by Boole — small diffs only (the GLOAS TODO + fork list pre-exist on stage, so these seams are *more* stable than feared). The one relevant change is the beacon fork method rename `ForkAtEpoch` → `BeaconForkAtEpoch` (`beacon.go:141` on `boole-fork`), done to disambiguate from Boole's new SSV-fork `BooleForkAtEpoch`. Model `IsGloas` on the **beacon** method (`BeaconForkAtEpoch`), **not** the SSV-fork `BooleForkAtEpoch` — Gloas is beacon-driven (see §0). -- **`BeaconVote` ↔ `GloasBeaconVote`** selected by the duty slot's fork; SSZ length differs (112B vs 120B) so cross-fork decode fails cleanly. -- **Slashing: no change** (see §0). **Parameterizes T1, T4.** - -### U6 — Local-build / reconstruction telemetry **(decided)** -Local-build rate: counter split on `api.VersionedProposal.Blinded` (`blinded=false` ≈ local; a **pre-Gloas proxy** — the signal changes post-fork). PTC reconstruction-miss and ProposerPreferences reconstruction-failure: new counters in those runners. **Parameterizes T13** (and informs T8 priority). **Logs-first (§8): these counters are nice-to-have viz only; the primary, automatable validation is the matching greppable DEBUG log (§8 G2 build-source, G3 duty-outcome, and the reconstruction-miss logs) — never the metric alone.** - -### Newly confirmed (folded into tracks) -- Sync-committee path inert to the new vote field → noted in T4. -- `GLOAS_FORK_EPOCH` schedule is external (Glamsterdam ~Q3 2026; devnets now) → T11 / §2b. - ---- - -## 2b. Remaining open items (not resolvable now) - -| Item | Why open | Handling | -|------|----------|----------| -| **Boole→stage landing (scheduled)** | `boole-fork` (canonical) hasn't merged yet — ~46 behind stage, tip Apr 2026 — but lands **within ~2-3 weeks (≈ mid-July 2026), treated as ground truth**. Still the serial gate for **Boole → ePBS → migration-M1**. | **ePBS starts now off `boole-fork`** (decided — needs independent testing in parallel, can't wait) and rebases onto stage at landing (§6); the 46-commit reconciliation lands with the merge. Residual risk only if the 2-3 weeks slips — monitor. *(Update: `boole-fork` refreshed via #2899/#2900; ePBS rebased onto it — small reconciliation surface, see §6.)* | -| **U1 — §6 QBFT vs no-QBFT** | ~~Genuine design call~~ **RESOLVED → QBFT** | SIP #94 maintainer (GalRogozinski) call, 2026-06-23: keep QBFT (handles the faulty-leader case; reuses existing machinery, no new message class). Contained to T8 (ships last). | -| **go-eth2-client Gloas support** | Absent upstream | Build full Gloas types + endpoint clients **node-side now** (T2); swap for upstream `spec/gloas` as a later **dedup** when it ships — not a gate | -| **produceBlockV4 + envelope endpoints** | beacon-APIs#580 unmerged, may churn | Implement node-side against #580; pin + watch for churn; e2e on the local Gloas devnet (T2/T7/T8) | -| **`SignedProposerPreferences` publish endpoint** | Doesn't exist upstream yet | Abstract `SubmitProposerPreferences`, mock; **T5 publish can't be e2e-tested against a real BN until it lands** | -| **Remote-signer (Web3Signer) on Gloas — broken for all duties** | Three layers (full detail + fixes in §7 "Remote (Web3Signer) signing on Gloas"): (1) `fork_info` carried the **Fulu** version on Gloas (`GetForkInfo`→`BeaconForkAtEpoch` caps at Fulu) → **every non-pinned remote duty** (attestation/sync/aggregation/block) got the wrong domain; (2) §4 block hits the converter's `obj type is unknown` default (the Gloas block type isn't nameable in the separate ssvsigner module); (3) §3/§5/§6 have no Web3Signer request type. | (1) **✅ DONE** — scoped Gloas `fork_info` in `GetForkInfo` (via the existing `ForkAtVersion` interface method; ssvsigner mirrors the Gloas data version locally, module boundary); unblocks all existing remote duties. (2) **deferred, module-boundary design** — pass the block header (`phase0.BeaconBlockHeader`) across instead of the Gloas block (+RS-1); cross-system-gated. (3) **upstream-blocked** — Web3Signer must add the types; local-sign meanwhile, **bounded by `f`**. Local signing unaffected; fail-safe (liveness). Voluntary-exit + validator-registration are domain-pinned → exempt from (1). Operator-facing — surface in the PR description. | -| **`GLOAS_FORK_EPOCH` value** | Ethereum hasn't scheduled it (Glamsterdam ~Q3 2026) | Fetched from BN at runtime; develop/test on devnets; no config change | -| **consensus-specs pin drift** | Spec still pre-final | Re-verify pin at start; the SIP's own watchlist tracks normative drift | -| **Runtime rates** (local-build %, PTC/prefs reconstruction-miss %) | Only measurable in production | Ship telemetry (U6/T13) — **nice-to-have viz; primary validation is the §8 greppable logs** — revisit §6 priority and any no-QBFT tuning post-deploy | -| **Anchor wire-constant lock** | Cross-client agreement; cheap now, expensive at interop | **Partially verified** (sigp/anchor `epbs` branch): PTC constants match exactly (`Role::PTCAttester=7`, `PartialSignatureKind::PTCAttester=7`, validator-scoped); domains `0x0B/0C/0D` + domain epochs (`epoch(proposal_slot)`/`epoch(data.slot)`) match consensus-specs = #632. Anchor hasn't built §5/§6 yet (PTC-first, like us) → SIP + consensus-specs are the shared reference (verified); re-check §5/§6 constants when Anchor adds them. | -| **Full migration off ssv-spec** (all duty types) | Direction **decided**; execution is a separate, larger initiative | Out of scope here; ePBS adds Gloas types node-side as the first down-payment (see U0). Main cost (spectest decoupling) is the migration's, not ePBS's | - ---- - -## 3. Implementation tracks (dependency-ordered) - -### T1 — Types + fork-gating scaffolding **(needs U0, U5)** -Per U0: in-tree `gloastypes` package with the SIP-canonical enums/domains/structs (`GloasBeaconVote` (+SSZ, 120B), `EnvelopeConsensusData`, `BlindedExecutionPayloadEnvelope`, roles/partial-sig/domains). Add the `IsGloas` helper and the `ForkAtEpoch` Gloas entry (`networkconfig/beacon.go`). `DataVersionGloas` comes from the T2 fork patch. **Placement (migration-aligned):** land `gloastypes` as a subpackage of the eventual bridge home `protocol/v2/types` (e.g. `protocol/v2/types/gloas`), not a standalone top-level package — so the ssv-spec migration absorbs it into one node-type root without a relocation, and its M1 codemod sweeps it like any other file (migration plan §1). On `boole-fork`, `protocol/v2/types` already hosts node-side `runner_role.go`/`consensus_data.go`/`partial_sig_message.go`, so `protocol/v2/types/gloas` sits beside real protocol-type siblings (placement A confirmed). Model `IsGloas` on the **beacon** fork method (`BeaconForkAtEpoch`), not the SSV-fork `BooleForkAtEpoch` (per U5/§0); `SSVForks` is no longer empty (it has `Boole`) — decide per §0 whether Gloas needs its own entry. - -### T2 — BeaconNode abstraction + full node-side Gloas types & endpoint clients **(needs U2, U3)** -Per U2 (revised — full impl): add the U3 interface methods to `client.go`; define **full-fidelity Gloas types node-side** (in the `gloas` package — SSZ + JSON per `consensus-specs@6ebb2216c` + beacon-APIs#580) and implement the `beacon/goclient/` **endpoint clients (real HTTP)** — not placeholders. Regenerate mocks for unit tests. Tag each method by maturity tier (merged / #580 / endpoint-missing) so the churn surface is explicit. **Order:** PTC call-set first (merged endpoints → live-devnet-testable soonest), then ProposerPreferences (mock-only — no endpoint), then envelope + produceBlockV4 (#580, watch). The go-eth2-client rebase later swaps our node-side types/clients for upstream's `spec/gloas` (a dedup), not a gate. *(`DataVersionGloas` placeholder + `IsGloas` and the `GLOAS_FORK_EPOCH` wiring already landed.)* - -### T3 — §1 slot timing **— done** (uncommitted) -The key realization: ePBS retimes duties from **thirds to quarters**, and every deadline is `N × IntervalDuration` with **N preserved across the fork** (attestation/sync 1×, aggregate/contribution 2×, PTC 3×); the Gloas bps (2500/5000/7500) are exactly 1/4, 2/4, 3/4. So the whole change is one fork-gate: `(*Beacon).IntervalDuration()` → `IntervalDuration(slot)`, returning `SlotDuration/3` pre-Gloas and `SlotDuration/4` from Gloas on. Every `N × IntervalDuration` caller then lands on the right quarter automatically — scheduler attestation timer (`SlotTicker`) + head-event acceleration check, attester/proposer indices-change deadline, the aggregator-committee runner, and `goclient` aggregator + sync-contribution. **Pre-Gloas is byte-identical** (`TestNetwork` has no Gloas). The misleadingly-named `waitOneThird*`/`waitTwoThirds*` helpers were renamed `waitOneInterval*`/`waitTwoIntervals*` with fork-aware comments/logs. PTC's 75% (`PayloadAttestationCutoff` = 3/4) was already correct — untouched. Unit test `TestBeacon_IntervalDuration` (thirds before the fork, quarters from it on). - -**QBFT round-1 head-start — deliberately not retimed (2026-06-27).** `roundtimer/timer.go`'s `round1HeadStart` (`slotDuration/3` committee, `*2/3` aggregator/sync-contribution — the pre-round-1 wait for the block/attestations to arrive) is the one timing constant *not* routed through `IntervalDuration`, so it stayed at thirds post-Gloas. It is QBFT round-change *liveness* timing, not one of the SIP §1 duty deadlines (all retimed above), so retiming it is out of §1 scope; the post-Gloas misalignment (head-start 1/3 vs the now-1/4 attestation deadline) only loosens leader-rotation timing on the slow path, with no correctness impact. **Decision: leave as-is** — revisit only if consensus timing proves problematic under the tighter ePBS schedule on devnet. (Surfaced by a §1 review.) - -### T4 — §2 attestation / `GloasBeaconVote` **(needs T1, T2, U5) — scoped against Anchor (2026-06)** -**The type already exists** (T1 built `protocol/v2/types/gloas/beacon_vote.go` — `GloasBeaconVote{BlockRoot, Source, Target, AttestationDataIndex}`, 120B SSZ, field-order- and wire-identical to Anchor's `GloasBeaconVote`), referenced nowhere outside its package. So **T4 is pure wiring**, not new types. - -**Anchor's `GloasBeaconVoteValidator` = the pre-Gloas validator + exactly two rules:** (1) range-check `attestation_data_index ∈ {0,1}` (reject ≥2); (2) reconstruct the slashing-check `AttestationData` with the single QBFT-decided index. Everything else (far-future target, source) }` and passes it to the proposer's `StartEnvelopeDuty`. `c.ExecuteDuty` enqueues (async) + routes by pubkey, and `RunnerRoleForValidatorDuty` → `duty.RunnerRole()` → `RoleEnvelopeBuilder` on Gloas (post-Boole) slots, so no role-map change was needed. - - **⑥ message validation — DONE** (committed): the role-9 arm across the validation rules — `validRoleAtSlot` (Gloas-only), `maxRound`=2 + the round-spread skip + `messageLateness` (all like the proposer — QBFT, instance-relative timing), `partialSignatureTypeMatchesRole` (post-consensus only, no pre-consensus), `dutyLimit`=SlotsPerEpoch (≤1 self-build envelope/slot). `committeeRole`/`monotonicSlotRole`/`validateBeaconDuty`/`storedSlotCount` unchanged — not a committee role, not beacon-scheduled, has consensus. Per §0 no infra change (state is `MessageID`-keyed, incl. `RunnerRole`). 5 unit tests. - - **Heavy payload — DONE** (committed): the full Gloas `ExecutionPayload` (Deneb's + `block_access_list` [EIP-7928 — an opaque `ByteList`, *not* the feared nested SSZ: the EL RLP-encodes it, the CL only stores+hashes] + `slot_number` [EIP-7843]; `base_fee_per_gas` as `[32]byte` SSZ uint256), the full `ExecutionPayloadEnvelope`/`SignedExecutionPayloadEnvelope` + a `Blinded()` transform, and goclient `Get`/`SubmitExecutionPayloadEnvelope` (best-effort #580 paths, octet-stream SSZ like §4). The runner's `produceBlindedEnvelope`/`submitEnvelope` are filled (fetch→cache→blind / content-match→holder publishes). **e2e QBFT test — done** (committed): `runner/envelope_e2e_test.go` adapts the proposer's heavyweight harness, covering the post-consensus publish both via direct `submitEnvelope` (builder publishes / competing-envelope operator skips) and a full `ProcessPostConsensus` (share-signed partial-sig quorum under `DOMAIN_BEACON_BUILDER` → reconstruct → publish). **HTR-parity — done** (committed): the `ExecutionPayload` field order, `BlockAccessList` bound (`ByteList[2**30]`), and `slot_number` placement were verified field-for-field against the canonical container (consensus-specs `specs/gloas/beacon-chain.md` @ `6ebb2216c`); `TestExecutionPayloadLayoutMatchesSpec` pins the HTR as a drift guard. **Deferred (devnet-gated):** the `…Contents` blob-carrying publish body — it carries `envelope + blobs + kzg_proofs`, but there's no blob source (T7 defers the §4 `BlockContents`; the proposer caches only the bare block `cachedGloasBlockSSZ`), the #580 publish endpoint accepts *either* the bare envelope (stateful — what SSV does, working) *or* the Contents (stateless), and SSV self-build is naturally stateful (its own BN built + holds the §4 payload). So the Contents form is needed only if a devnet BN runs payload-stateless — revisit then (it also un-defers T7's blob plumbing). **TODO** — a *computational* HTR cross-check against published canonical Gloas SSZ spec vectors once the fork ships (none exist for the unreleased fork yet, so today's check is against the spec *source* + a drift guard, not vectors). -- **Recommendation when resuming:** build ④ as the first focused step (consensus-critical — don't rush it at a session tail), then ⑤/⑥, then the heavy payload last (devnet-validated). - -### T9 — Message validation for new roles **(needs U4; pairs with T5/T6/T8)** -Implement the U4 rule arms: `validRole`, `partialSignatureTypeMatchesRole`, `dutyLimit`, `messageEarliness`/`messageLateness`, `maxRound`. Dual-instance needs **no infra change** (state is `MessageID`-keyed, per §0) — just add the role arms. - -**ProposerPreferences: done** (uncommitted). Mechanical arms mirror PTC (`validRoleAtSlot`=`isInGloas`, `partialSignatureTypeMatchesRole`, `validPartialSigMsgType`, pre-consensus limit, `seen_msg_types`, no-consensus reject). Plus the **multi-future-slot reconciliation** the role forced — its signer holds the whole lookahead at once, which the per-signer state machine (built for monotonic one-slot-at-a-time) didn't fit: (1) `messageEarliness` allowance = the lookahead span (`proposerPreferencesEarlyEpochs`=2 epochs); (2) **exempt from the monotonic `ErrSlotAlreadyAdvanced` check** (`monotonicSlotRole`) — else a higher proposal slot poisons lower ones (devnet-frequent); (3) **`messageLateness` past bound** (replaces the dropped monotonic replay protection); (4) **per-signer ring sized to the lookahead** (`storedSlotCount(role)`) so concurrent lookahead slots don't collide and per-slot dedup stays exact. 7 unit tests. -**Spam hardening: done** (uncommitted). Per-epoch `dutyLimit` arm (`SlotsPerEpoch`) + a duty-assignment check in `validateBeaconDuty` (the proposal slot must be a real assignment via `dutyStore.Proposer`, with RANDAO-style tolerance — accept while the slot's epoch is unfetched, since the duty fetch may be in flight). Layered with the earliness/lateness window + committee membership + the runner's expected-root check. - -### T10 — Runner registration & wiring **(needs U0; T5, T6, T8 runners)** -Register the new roles in `SetupRunners` (`operator/validator/controller.go`, `runnersType` + `switch`). **Correction (verified building T5):** the node duty→role map `RunnerRoleForValidatorDuty(duty, isBooleFork)` needs **no** new arm — its Boole branch already returns `duty.RunnerRole()`, which ssv-spec maps for the new BN roles (the same path PTC uses); and per-role queues **auto-create** from the registered `DutyRunners` (`validator.go:~81` ranges `options.DutyRunners`), so no `validator.go` change. **ProposerPreferences: done** (registered with `FeeRecipientProvider`=validatorStore + `GasLimit`; PTC already registered). **Pending:** EnvelopeProposer (T8). - -### T11 — Fork cutover & transition **(needs T3–T8)** -`GLOAS_FORK_EPOCH` is fetched from the BN at runtime (external, ~Q3 2026; lives in `Beacon.Forks[DataVersionGloas]`, gated by `(*Beacon).IsGloas`); the ValidatorRegistration→ProposerPreferences switchover at the boundary; the pre-fork preferences emission window; transition tests (boundary epoch where old + new coexist). **Mirror Boole's transition machinery rather than inventing it:** `InBooleTransitionWindow` / `inBoolePriorWindow` / `inBooleSubsequentWindow` (`networkconfig/network.go`, with `boolePriorWindowEpochs` / `booleSubsequentWindowSlots`) is the direct template for both the Gloas cutover and the pre-fork ProposerPreferences emission window (U4/T5). Develop/test on devnets until a public testnet schedules the fork. -- **VR deprecation: done** (committed). Gloas-gated stop at all three points: `validRoleAtSlot(RoleValidatorRegistration)` rejects Gloas-or-later slots; the VR duty handler skips emission (periodic + event enqueue); the periodic `VRSubmitter` stops submitting. Added `networkconfig.TestNetworkWithGloas(epoch)` test fixture (TestNetwork has no Gloas fork) + 2 tests. The VR runner stays registered but goes idle (handler emits nothing). -- **Pre-fork emission: done** (uncommitted). `Beacon.GloasForkEpoch()` + `Network.InGloasPriorWindow(slot)` (mirrors `inBoolePriorWindow`, `gloasPriorWindowEpochs = MIN_SEED_LOOKAHEAD = 1`); the prefs handler's per-tick logic extracted to `emitForTick`, which in the prior window pre-emits the first Gloas epoch's preferences (`emitForEpoch(epoch+1)`); 3 tests. **So T11 = done** (cutover both directions). Remaining: end-to-end boundary testing belongs with the broader e2e effort. -- **Cleanup (carry here):** `DefaultGasLimit` and the `feeRecipientProvider` interface live in `protocol/v2/ssv/runner/validator_registration.go` but are now also consumed by `proposer_preferences.go` (the runner). Since this fork deprecates the ValidatorRegistration runner, relocate both to a neutral file (e.g. `runner.go`) as part of the cutover so removing VR doesn't orphan them. Compiler-caught, not silent — safe to defer to here. - -### T12 — Testing **(per track + integration + e2e)** -Unit tests per runner/handler/validation arm (against T2 mocks); fork-boundary tests; **e2e on a local Gloas devnet** — the full-impl decision (U2) makes this the real acceptance bar. **e2e vehicle — ssv-mini** (`github.com/ssvlabs/ssv-mini`): the SSV-labs Kurtosis stack already runs Lighthouse + Geth + the SSV layer (4 operators, validators, contracts via Hardhat) at the **Boole** fork. **Prerequisite (a task in the ssv-mini repo):** bump its Ethereum layer from **Fulu → a Glamsterdam/Gloas-capable Lighthouse + Geth** with Glamsterdam fork params (mirror the ethpandaops `glamsterdam-devnets` configs); then run the Gloas SSV implementation e2e against it. Until ssv-mini gains Gloas, unit tests + pointing SSV at an external ethpandaops Glamsterdam devnet cover it. `SignedProposerPreferences` stays unit/mock-only (no endpoint anywhere). **Spectests:** Gloas has no ssv-spec vectors, so the node-side Gloas types add no spec-test surface — the broader migration owns spectest decoupling, not ePBS. - -### T13 — Telemetry / metrics / logging / docs **(uses U6; parallel)** -U6 metrics (Blinded-split local-build counter; PTC/prefs reconstruction-miss counters) — **nice-to-have viz only (§8)**; the **primary validation surface is the structured DEBUG logs** for the new duties (every behavior greppable — see the §8 logs-first audit); operator docs for new config. - ---- - -## 4. Dependency & sequencing - -``` -Inputs (resolved unless noted): - U0 ssv-spec strategy -> feeds T1, T7, T10 (resolved: migrate off ssv-spec) - U1 §6 QBFT vs no-QBFT -> feeds T8 (RESOLVED — QBFT) - U2/U3 go-eth2-client+iface -> feeds T2 - U4 msg-validation model -> feeds T5, T6, T9 - U5 fork-gating (+slashing) -> feeds T1, T4 - U6 telemetry signal -> feeds T13 - -Implementation ("X <- Y" = X depends on Y): - T1 <- U0, U5 - T2 <- U2, U3 - T3 <- T1 - T4 <- T1, T2, U5 - T5 <- T1, T2, U4 (publish step: mock-only until upstream endpoint) - T6 <- T1, T2, U4 - T7 <- U0, T1, T2 (#580-pinned) - T8 <- T1, T2, T7 (ship last; #580-pinned; U1 resolved -> QBFT) - T9 <- U4 (alongside T5/T6/T8) - T10 <- U0, T5, T6, T8 (U0 values + the new runners) - T11 <- T3..T8 (fork cutover; external GLOAS_FORK_EPOCH) - T12 per-track + integration - T13 <- U6 (telemetry; parallel throughout) -``` - -**Suggested order:** **T1 + T2** (foundations; T2 is now full node-side Gloas types + real endpoint clients, e2e-testable on a live devnet) → **T3, T7** → **T4** → **T5, T6** → **T9/T10** woven in → **T8** (after U1) → **T11/T12** → **T13** throughout. - -**Two completion lines (and the migration handoff).** ePBS finishes along two separate axes — don't conflate them: -- **Node-side complete (full impl):** T1, T3–T6, T9, T10, plus the T7/T8 structure — built on the node-side Gloas types + real `beacon/goclient` endpoint clients (T2), unit-tested against mocks. Independent of the go-eth2-client rebase. -- **Live-devnet validated:** e2e against a local Gloas devnet (ssv-mini once Gloas-bumped, or an external Glamsterdam devnet — T12). The real interop bar; mock-green ≠ interop-green. `SignedProposerPreferences` is the lone gap (no endpoint anywhere), so it stays unit/mock-only. - -The go-eth2-client rebase is a separable, later **dedup** (swap our node-side types/clients for upstream's `spec/gloas`) — not an integration gate; it can land any time, even post-fork. - -The ssv-spec migration's handoff gates on **node-side-complete** (including T8's node-side structure, since T8 ships last and the migration's repo-wide codemod can't run concurrently with T8's QBFT/message-validation edits), *not* on the go-eth2-client dedup: the codemod begins once ePBS's node-side tracks merge, and the dedup (separable, possibly post-fork) does not gate it (see migration plan §6). - ---- - -## 5. Decisions log - -| ID | Decision | Owner | Affects | Status | -|----|----------|-------|---------|--------| -| U0 | Node-side Gloas types (SIP values); **migrate off ssv-spec entirely (end-state)** — ePBS adds types node-side, never modifies/bumps/PRs ssv-spec; **on `boole-fork`: T7/T10 extend Boole's existing node-side switches** (`consensus_data.go`/`runner_role.go`), consolidation is now baseline (not a bump); **lock Anchor constants early** (not end-of-execution) | node + Anchor coord | T1, T7, T10 | **resolved** | -| U1 | §6 QBFT vs no-QBFT(sign-all) → **QBFT** | SIP #94 maintainer (GalRogozinski), 2026-06-23 | T8 | **resolved** (QBFT; faulty-leader handling + no new message class outweigh no-QBFT's latency/Byzantine edge for a rare path) | -| U2/U3 | **Full node-side impl**: build Gloas types + endpoint clients now (e2e-testable on a live devnet via ssv-mini); mocks for unit tests; upstream go-eth2-client rebase = later **dedup**, not a gate | node | T2 | **resolved** (revised — full impl) | -| U4 | Msg-validation model; ProposerPreferences carries `proposal_slot` (= `duty.Slot`), future-slot allowed via a role-specific `messageEarliness` exemption (T9) | node | T5, T6, T9 | **resolved** | -| U5 | Gate Gloas by beacon epoch; slashing needs no change. Post-Boole: `SSVForks` now has `Boole` (pre-Boole "empty struct" basis corrected) — Gloas stays beacon-gated; re-pin `spec.go`/`beacon.go` anchors | node | T1, T4 | **resolved** | -| U6 | `Blinded`-split local-build metric (pre-Gloas proxy) + recon-miss counters — nice-to-have viz; §8 logs are primary | node | T13 | **resolved** | -| — | produceBlockV4 + envelope endpoints | upstream | T2/T7/T8 | **resolved** (beacon-APIs#580 merged 2026-06-29; §4→v4 produce `include_payload=false`, §6→blinded publish wired; go-eth2-client dedup still pending) | -| — | `SignedProposerPreferences` publish endpoint | upstream | T5 | **resolved** (endpoint merged: `POST /eth/v1/validator/proposer_preferences`; §5 publishes for real, sentinel removed) | -| — | §5 proposer-preferences **re-emission** (reorg/`dependent_root` change) vs the ≤1-per-`(slot,signer)` pre-consensus dedup | node + SIP | T5 | **BLOCKED — pending SIP-94 §5** ([discussion](https://github.com/ssvlabs/SIPs/pull/94#discussion_r3499380025)); current `reEmitLookahead` is broken (penalty + non-convergence); proposed fix + interim in the §7 DEFERRED block | -| — | `GLOAS_FORK_EPOCH` schedule | Ethereum | T11 | external (Glamsterdam ~Q3 2026; devnets now) | -| — | Anchor wire-constant lock | node + Anchor | T1 | **PTC verified vs sigp/anchor `epbs` (matches); domains = consensus-specs = #632**; §5/§6 not in Anchor yet — re-check when added | -| — | go-eth2-client upstream Gloas + fork rebase | upstream | T2 | optional **dedup** — we implement node-side now; swap for upstream `spec/gloas` when it ships | -| ssv-mini e2e | Adopt ssv-mini (Kurtosis: Lighthouse+Geth+SSV) as the Gloas e2e vehicle; prerequisite = bump its Eth layer Fulu→Glamsterdam | node + ssv-mini repo | T12 | **resolved** (direction) | -| Boole | **Baseline = `boole-fork` (canonical Boole branch)**; lands on stage in **~2-3 weeks (≈ mid-July 2026, ground truth)**. **Decision: ePBS starts now off `boole-fork`** (parallel independent testing, can't wait) + `rebase --onto stage` at landing (§6). consolidation/role-6/`ProposerConsensusData`/`SSVForks{Boole}`/transition-windows live on `boole-fork` until then | node | all tracks | **resolved** | - ---- - -## 6. Branch & rebase workflow (ePBS starts now off `boole-fork`) - -**Decision: start ePBS now off `boole-fork`, in parallel with Boole's finalization.** ePBS must begin immediately for independent development + testing — and that's achievable now: the *node-side-complete-on-mocks* milestone (T2 BeaconNode mocks; see §4) doesn't gate on Boole landing or upstream, so ePBS can be built and exercised against mocks/devnet while Boole is being finalized. *(The alternative — wait ~2-3 weeks and build off post-Boole stage — was simpler but rejected: ePBS can't wait.)* - -**Don't build off pre-Boole `stage`:** ePBS lives in the exact files Boole heavily changes (`operator/duties/`, the committee/aggregator runners + `value_check.go`, `message/validation/*`, controller `SetupRunners`, the node-side `protocol/v2/types/{runner_role,consensus_data}.go`). *(Note `beacon/goclient/spec.go` / `networkconfig/beacon.go` are only small diffs — see U5.)* Branch off `boole-fork`. Building on `stage` and rebasing across the Boole merge later is a *rewrite*, not a rebase — every hunk on T4–T10 conflicts against Boole-restructured code, and you'd build to the pre-Boole design (re-creating the `runner_role.go`/`consensus_data.go` switches Boole already has). - -**Path:** -1. **Branch off a `boole-fork` tip now.** You get the correct baseline immediately — `RoleAggregatorCommittee=6`, `ProposerConsensusData`, the node-side switches to extend, `SSVForks{Boole}` + transition-window machinery to mirror. Build it right the first time. -2. **Track `boole-fork` until it lands (~2-3 weeks).** It's ~46 behind stage (Apr 2026), so you're developing on **Apr-stage**; `boole-fork` will be refreshed against stage before/at landing, so merge its updates into the ePBS branch as they appear and **re-run the independent tests after absorbing the refresh** (the Apr→Jun reconciliation can touch your files). Bounded by the ~2-3 week horizon, then `rebase --onto stage` at landing. -3. **When Boole merges to `stage`, move only the ePBS commits onto stage:** - ``` - git rebase --onto stage - ``` - This replays just the ePBS commits (skipping Boole's, now in stage). Clean whether Boole→stage was a **merge/FF** (Boole's commits are literally in stage) or a **squash** (the `--onto` form sidesteps the duplicated-commit conflicts a plain `git rebase stage` would hit). - -**Coordinate one thing:** nudge whoever merges Boole→stage toward a **merge-commit or fast-forward over a squash** — then even a plain rebase is clean and `--onto` is just insurance. - -**What this unblocks now:** branched off `boole-fork`, essentially all node-side ePBS work (T1, T3–T7, T9, T10, T13) can start immediately on the correct baseline. The only still-blocked items are blocked *regardless of Boole*: T8 (U1 design call) and the upstream go-eth2-client rebase / #580 endpoints (external). - -**Accepted cost:** starting now means ePBS eats a rebase across the Boole landing (the 46-commit stage reconciliation + the merge) and a re-validation pass after absorbing `boole-fork`'s refresh. That's the deliberate price of getting independent ePBS testing going in parallel — bounded by the ~2-3 week landing horizon, and the `--onto` mechanics keep the final move mechanical. - -**First incremental rebase — done** (validates the approach — small surface, not a big-bang). `boole-fork` was refreshed (#2899 + #2900); ePBS rebased onto it with two reconciliations: **scheduler.go** — boole-fork's new `dutySlotIsExecutionSlot` lateness guard was *combined* with the PTC cutoff baseline (the guard is true for PTC, so it doesn't replace it); **ptc_attester.go** — boole-fork renamed the runner completion API (`finishDuty`/`ErrRunningDutyFinished` → `markDuty*`/`ErrRunningDutySucceeded`), so abstains now use `markDutyNotRequired`. go.mod/go.sum auto-merged cleanly; #2900 also fixed a pre-existing Electra aggregate-index test. The `rebase --onto stage` at landing (step 3) still applies. - ---- - -## §7 — ePBS e2e Execution Plan (active; node-side complete) - -PTC is implemented node-side end-to-end (wire types → goclient endpoints → ekm signing → `PTCAttesterRunner` → `SetupRunners` registration → scheduler handler with the 75% trigger → message validation; ssv-spec ePBS constants via PR ssvlabs/ssv-spec#632, go.mods pinned to its commit). This supersedes the T12 sketch with the concrete plan. - -### Devnet run 2026-07-01 — §4 block production WORKS on devnet-5 (retry fix validated); new blockers -An aetheria `(proposer)` e2e run on `local_testnet_gloas` (`GLOAS_FORK_EPOCH=2`, SSV indices 64–73, **LOCAL signing**). **The original "no eligible validators" finding is closed** — the retry fix restores `📚 got duties (PROPOSER)` at loaded epochs, and the `🔬` diagnostic ladder confirmed the duty is *dispatched* (`in_committee=1, executable=1`), i.e. the remaining loss was downstream. Two downstream bugs sat behind it, both **fixed + on the branch**: -- **graffiti 400** (`e1412f656`) — `requestGloasBeaconBlock` didn't pad graffiti to 32 bytes; Lighthouse rejected the produce query. Fixed (pad to `[32]byte`, mirroring `GetBeaconBlock`). -- **local-signer `obj type is unknown: *gloas.BeaconBlock`** (`3c21d06fa`) — `LocalKeyManager.signBeaconObject`'s `DomainProposer` switch had no Gloas case (ssvsigner can't name the node's block type); now signs the SSZ root directly via `signSSZRoot`, like the other Gloas domains. - -With both, **v64's block lands on-chain** (devnet-5 slots 464, 529); §2 attestations work throughout. New findings, tracked in §1b: -- **✅ Gloas local block slashing protection — DONE.** `3c21d06fa` initially signed the root directly with no protection (a block *is* slashable, unlike PTC/prefs/envelope); now `SignBeaconObject` plumbs the slot through and the Gloas case checks `IsBeaconBlockSlashable` + records the highest proposal (serialized via `blockProposalLock`) before signing. Test: a re-proposal at the same slot is rejected. -- **devnet-6: `could not submit gloas beacon block: invalid SSZ`** — produces + signs, but the newer BN rejects the encoding → the node-side Gloas block SSZ has **drifted from devnet-6's spec** (the consensus-specs-pin-drift watchlist materializing). Blocks §4 on the *target* devnet — needs the on-wire field diff. -- **§6 envelope produce 404 (needs devnet correlation).** `executeDuty` gets the §4-decided block root and calls `GetExecutionPayloadEnvelope(slot, root)` → GET `/eth/v1/validator/execution_payload_envelopes/{slot}/{root}` (`beacon/goclient/gloas_envelope.go`). A 404 means the BN doesn't have that block/envelope yet (timing — the envelope duty may fire before the §4 block is imported) or doesn't serve the #580 endpoint. **Can't resolve node-side blind** — needs the exact 404 from a devnet run: the path the BN echoed, the response body, and whether the §4 block had landed at that point. -- **✅ §2 attestation Index rejection — DONE (was HIGH, NOT transient).** Root cause: go-eth2-client v0.27.0 hardcodes `data.Index == 0` for *all* post-Electra slots and rejects anything else with `ErrInconsistentResult` (`http/attestationdata.go`). On Gloas, `Index` is the payload-status (0=EMPTY / 1=FULL, SIP #94 §2), so a **FULL payload — the healthy case — is wrongly rejected**, failing the attestation. The devnet saw it once only because payload was mostly EMPTY during that run; on a healthy chain it would break most attestations. Fixed: `fetchAttestationData` routes Gloas slots to a hand-rolled `attestation_data` GET (`beacon/goclient/attest.go`) that skips the check and keeps the BN index; the aggregate path (`computeAttestationDataRoot`) benefits too. Trades the weighted multi-BN selection for first-client on Gloas (follow-up if reward impact matters). Test added. - -**Correction to the RS-1/RS-2 framing below:** "local signing unaffected" held only for **RS-1** (the fork_info/domain issue); local §4 **block** signing hit the *same* obj-type gap (now fixed). Both `LocalKeyManager` and `RemoteKeyManager` live in the ssvsigner module, so neither could name `*gloas.BeaconBlock`. - -### Update 2026-06-30 — §4/§5/§6 wired to the merged beacon-APIs (#580) -[beacon-APIs#580](https://github.com/ethereum/beacon-APIs/pull/580) merged 2026-06-29 and the `proposer_preferences` validator endpoint is in master, so the three endpoints that were abstract/stubbed are now implemented against the real merged paths. (go-eth2-client still has no Gloas types, so they stay hand-rolled HTTP — the typed dedup is unchanged and post-fork-OK. The older T5/T7/T8 notes below predate this and are superseded here.) -- **§5 proposer preferences** — `SubmitProposerPreferences` POSTs to `/eth/v1/validator/proposer_preferences` (JSON); the `ErrProposerPreferencesPublishUnavailable` sentinel + the runner skip-branch are removed; goclient test added. ⚠️ The *publish* path is done, but the reorg/`dependent_root` **re-emission** is a separate open issue — see the **DEFERRED** block below. -- **§4 proposer block** — produce switched v3→**v4** (`/eth/v4/validator/blocks/{slot}?…&include_payload=false`): a Gloas block is bid-only, so the response stays a bare `BeaconBlock` (no `BlockContents`). -- **§6 envelope** — produce path → plural with `beacon_block_root` as a path segment; publish → plural, posting the **blinded** body (`SignedBlindedExecutionPayloadEnvelope`, new node-side SSZ type) with `Eth-Execution-Payload-Blinded: true` (stateful — the producing BN un-blinds from cache; no blob sourcing). -- **Remaining:** the stateless `SignedExecutionPayloadEnvelopeContents` body (full envelope + blobs/KZG, for cross-BN failover — needs blob sourcing); confirm the §6 body choice (blinded vs Contents) on a Gloas devnet; the go-eth2-client typed dedup. - -### ⚠️ DEFERRED (pending SIP-94 §5 decision) — proposer-preferences re-emission -**Do not implement until SIP-94 §5 specifies the coordination rule.** Discussion opened: . - -**Issue (PR review finding #2 + SIP-94 §5, lines ~200/202/329):** SIP-94 requires re-emitting a new `ProposerPreferences` when `dependent_root` changes for a proposal slot already in the lookahead. But SSV message-validation — and Anchor's `message_validator` (`MAX_MESSAGES_PER_ROUND = 1`) — enforce **≤1 pre-consensus partial sig per `(slot, signer)`**, content-agnostic. `ProposerPreferences` pins `duty.Slot` to the **fixed proposal slot** (unlike `ValidatorRegistration`, whose slot advances), so the re-emission is a duplicate `(slot, signer)` → rejected → (1) gossip penalty on the re-emitting operator, (2) the new-root preference can't reconstruct. So today's `reEmitLookahead` (clear-all → re-emit) is **broken**, not merely incomplete: §5 reorg/`dependent_root` refresh does not work. - -**To implement once the SIP decides** (proposed in the discussion above): -- **Validation:** dedup `ProposerPreferencesPartialSig` by `(slot, signer, signing_root)` — reject a repeat root (true duplicate), allow a *new* root up to a bound `N` (proposed `4`). Every other pre-consensus type keeps ≤1 (no regression). -- **Handler** (`operator/duties/proposer_preferences.go`): re-emit only when a proposal slot's `dependent_root` actually changes (track the emitted root per slot), so the bound is spent on genuine refreshes. -- Relaxes a **cross-client** invariant → must land in SIP-94 §5 and be matched by Anchor (no §5 there yet). - -**Interim (NOT applied; flagged):** if the penalty disrupts devnet testing before the SIP resolves, suppress no-op re-emits (option a) to stop the penalty — but that does **not** refresh on reorg (a deliberate SIP deviation), so only as a stopgap. - -**Committed on `epbs-gloas`** (rebased onto the refreshed `boole-fork` — see §6): the PTC implementation (above); two review rounds — first the `DataVersionGloas` → `networkconfig` / `BeaconForkAtEpoch` TODO / SSZ-regen tidy-up, then the 11-point PTC code review (unmasked-address requests, per-client timeouts, transient-BN warn, cutoff-baselined lateness, `signSSZRoot`, abstain semantics, handler tests); the `GlamsterdamDevnet` networkconfig stub; a `.dockerignore` `tla/` exclusion. **P1 image `ssvnode:epbs-gloas` builds + runs** (verified). - -### PR review round (2026-07-01) — §2/§4/§5/§6 spec-alignment sweep -A second review pass over the ePBS submit paths (findings #1–#4). **None are functional runtime bugs** — all are spec-alignment / SIP-coordination / documentation items. Only one new TODO (§5 publish-finality); the rest confirm or cross-ref items already tracked. -- **§5 publish-finality guard — NEW TODO; the one gap with no in-code note.** SIP-94 §5 says hold publication until a proposal slot's `dependent_root` / `fee_recipient` / `target_gas_limit` are final. The runner does not: `buildProposerPreferences` reads them at emit time and the preference is published on pre-consensus quorum (`protocol/v2/ssv/runner/proposer_preferences.go`), so it can publish on a non-final `dependent_root` — and, per the re-emission DEFERRED block above, can't be corrected afterward. Reorg-gated + §5 is observational → low severity. **Done:** `KNOWN ISSUE` comment added in `buildProposerPreferences` for parity with the re-emission one; implement the finality hold only if it bites on devnet. *(Promotes the "ProposerPreferences publish-finality follow-up" from the top-of-file delete-note into a tracked item — see §1b.)* -- **§5 re-emission (finding #1) — already tracked** (DEFERRED block above). Correction to the earlier "cheap interim" idea: `reEmitLookahead` does fire on **every** reorg (not just `dependent_root` changes), but gating it on `ReorgEvent.CurrentDutyDependentRootChanged` is **not** a safe one-liner — preferences span the current **and** next epoch, and that flag covers only the current epoch (the proposer handler always re-fetches the next epoch on any reorg), so a naive gate would suppress legitimate next-epoch refreshes. The `dependent_root`-change gate therefore belongs **with** the full §5 fix (same per-slot root tracking), not as a standalone interim. -- **§4/§6 stateless Contents (finding #2) — already tracked** (§7 "Remaining" above; `include_payload=false` + blinded/stateful publish, #580-pinned). Reconciliation angle: the divergence from SIP-94's `BlockContents` / `…EnvelopeContents` flow is deliberate — it tracks the *merged* beacon-APIs#580 that real BNs serve — so the fix is a **SIP-text update** (its watchlist authorizes it), not wiring Contents. Wire Contents only if a devnet BN proves payload-stateless. -- **§2 slashing-index (finding #4) — investigated, non-issue (no code action).** The Gloas payload-status index passed to `IsAttestationSlashable` (`value_check.go`) is inert: SSV's slashing protection (eth2-key-manager `NewNormalProtection`) compares **only** `source`/`target` epochs and explicitly stores no signing roots (verified in the lib). The code comment already states this and is accurate. At most a SIP-text rationale nuance. - -### Remote (Web3Signer) signing on Gloas — broken wider than §2b tracked (RS-1/RS-2/RS-3) -A PR-review sweep of the remote-signing path: the breakage is bigger than the old §2b row (which only covered the three *new* duties). **Local signing is unaffected by RS-1** (the fork_info/domain issue) — `LocalKeyManager.SignBeaconObject` signs the root computed from the BN-sourced `domain`, never `fork_info`. *(But local §4 **block** signing hit a separate gap — the same `obj type is unknown` block-type switch — now fixed; see the 2026-07-01 devnet-run findings above. "Unaffected throughout" was wrong.)* RS-1's impact is **liveness / fail-safe** (a rejected or wrong-domain sign, never a bad on-chain sig). Order of work: **RS-1 first** (one scoped change unblocks all existing remote duties), then RS-2, then RS-3 waits on upstream. -- **RS-1 — `fork_info` carries the Fulu version on Gloas → every non-pinned remote duty gets the wrong domain (node-fixable, highest leverage — ✅ DONE).** `prepareSignRequest` stamps `ForkInfo: GetForkInfo(epoch)` on each request; `GetForkInfo` → `BeaconForkAtEpoch`, whose version list stops at Fulu, so on a Gloas slot it returns the **Fulu** fork/version. Web3Signer derives the domain from `fork_info`, so attestation/sync/aggregation/block partial-sigs sign under the wrong domain → rejected or fail reconstruction (SSV also sends a correct BN-derived `SigningRoot`, so it's a mismatch-reject or wrong-domain sig — either way fail-safe). **Pinned domains exempt:** voluntary-exit (Capella) and validator-registration (genesis) override `fork_info`, so they keep working. **Fix (scoped, no ripple):** resolve the Gloas fork *inside `GetForkInfo`* via the **existing `ForkAtVersion(spec.DataVersionFulu+1)`** interface method — it returns the configured Gloas fork (the real `gloasForkVersion`, populated from the BN spec in `beacon/goclient/spec.go`); gate on `epoch ≥` its fork epoch (= `IsGloas`). **No interface change, no direct `Forks` access.** Do **not** extend `BeaconForkAtEpoch`: its `spec.DataVersion` return feeds ~8 callers (committee/aggregator/goclient submission tags) that deliberately cap at Fulu (its own `TODO(gloas)`). **Module boundary:** the ssvsigner module has its own go.mod and **can't import `networkconfig.DataVersionGloas`**, so it defines the Gloas data version locally as `spec.DataVersionFulu+1` (mirroring the placeholder). `RemoteKeyManager.beaconConfig` is `networkconfig.Beacon` in production (`operator/node.go`); the ssvsigner's own `beaconcfg.Config` (e2e) has the same Fulu cap but only matters if it ever computes signing `fork_info`. **Cross-system (devnet-confirm):** Web3Signer's `compute_domain` is generic over the version bytes, so the correct Gloas `fork_info` should suffice with no Web3Signer Gloas support — the only non-in-repo fact; confirm against a live Web3Signer. -- **RS-2 — §4 remote block signing: NOT a simple "add a case" (module boundary + cross-system; DEFERRED).** `handleDomainProposer` → `ConvertBlockToBeaconBlockData` type-switches on the block, but the Gloas block is `protocol/v2/types/gloas.BeaconBlock` (main module) and **the ssvsigner module has its own go.mod, doesn't require the main module, and imports no `gloas`** — so it *cannot* add a `case *gloas.BeaconBlock:`; the block falls through to `default: "obj type is unknown"` (`ssvsigner/web3signer/block_data.go`). *(The signing root is still computed correctly via `ComputeETHSigningRoot(obj, domain)` on the `ssz.HashRoot` interface; only `req.BeaconBlock` — the header BLOCK_V2 needs — can't be built.)* **Fix (design decision, spans both modules):** the node-side proposer path passes the **block header** (`phase0.BeaconBlockHeader`, a go-eth2-client type nameable in ssvsigner; `HTR(header) == HTR(block)`, so the signing root is unchanged) instead of the full Gloas block; the converter gains a `case *phase0.BeaconBlockHeader:`; `handleDomainProposer` supplies the Gloas version. **Cross-system:** whether Web3Signer accepts a Gloas-version BLOCK_V2 is unverified (may gate on Web3Signer Gloas support). Bigger than RS-1 and cross-system-gated → **deferred**; RS-1 already unblocks every remote duty except the §4 block. -- **RS-3 — §3/§5/§6 have no Web3Signer request type (upstream-blocked; already in §2b).** The PTC/preferences/envelope arms return descriptive errors + `TODO(gloas)`; `SignRequest` has no matching field. Gated on Web3Signer adding `payload_attestation` / `proposer_preferences` / `execution_payload_envelope` types; local-sign meanwhile (bounded by `f`). Point the `TODO(gloas)` arms at the upstream Web3Signer issue. - -### Gate check — PASSED -Make-or-break question for the public-devnet path: do the Gloas devnet CL clients expose the **beacon-API PTC validator endpoints**? (A Gloas chain can run with built-in VCs doing PTC internally without exposing them to an external VC like SSV.) They do: -- **Lodestar** `packages/api/src/beacon/routes/validator.ts` defines `getPtcDuties` (`/eth/v1/validator/duties/ptc/{epoch}`) and `producePayloadAttestationData` (→ `gloas.PayloadAttestationData`) — the exact URLs `beacon/goclient/ptc.go` calls. **Lighthouse** has the endpoints in `common/eth2` + a `payload_attestation_service`. -- `ethpandaops/glamsterdam-devnets` runs purpose-built Gloas images of every major client (`lighthouse`, `lodestar`, `prysm`, `teku`, `grandine`, …). **Live devnet = devnet-6** (as of 2026-06-30): `GLOAS_FORK_EPOCH: 30`, chain `7052886157`, genesis `1782386940` (≈ 2026-06-25 11:29 UTC) ⇒ **Gloas/ePBS active since ≈ 2026-06-25 14:41 UTC**. **devnet-5 is now Off; devnet-0..4 torn down** (dashboards 404). Devnets reset frequently and the repo README status table lags — **probe `https://glamsterdam-devnet-N.ethpandaops.io/` (→ 200) to find the live one, and re-check before every run** (it already moved 5→6). - -→ SSV operators pointed at a Lodestar/Lighthouse Gloas-devnet BN can run the full duties→produce→submit PTC flow. **The `devnet` initiative is feasible now.** - -### Shared prerequisites -- **P1 — PTC node image: DONE.** `ssvnode:epbs-gloas` builds + runs (verified). **No `GOPRIVATE` needed** for the build — the branch-pinned ssv-spec is in both go.sums, so `go mod download && go mod verify` resolves it via the public proxy without the sum-DB (GOPRIVATE is only for `go get`/`tidy`). Keep `tla/` out of the build context (`.dockerignore`) or local TLA+ scratch bloats `COPY . .`. -- **P2 — ssv-spec #632 merged** → re-point both go.mods at the cut version (drops the branch-pin; `go get`/`tidy` then no longer need `GOPRIVATE`). -- **P3 — DONE.** The deferred refinements: handler dependent-root/reorg refresh (committed) · PTC message lateness TTL + per-validator duty-count cap (committed) · PTC duty-assignment check (uncommitted) — a `dutyStore.PTC` (`Duties[gloas.PTCDuty]`) entry, with the handler reworked to broad-record every participating validator's duty in both operator+exporter modes (mirrors proposer/sync; `InCommittee` marks this node's own for execution) and an `IsEpochSet`-tolerant `RolePTCAttester` arm in `validateBeaconDuty`. Behavior is now sound under real-network reorgs/timing. -- **Observability (devnet watch):** PTC non-convergence (broadcast but no quorum — peers diverged on payload presence near the boundary) calls no duty marker, so `watchDutyOutcome` reports the generic "⚠️ likely stuck" at slot end. Framework-level (the runner has no slot-end hook), not PTC code; gauge the log frequency on the live devnet (devnet-6) before adding a distinct non-convergence outcome (tracked as §8 G5). - -### Two independent initiatives — run in parallel -`local_testnet` and `devnet` are **separate, independent validation initiatives** — neither blocks the other, and we want both running in parallel. They share only the build foundation (P1–P3 above: the `ssvnode:epbs-gloas` image + branch) and the greppable-log pass/fail signal; past that they diverge entirely (own networks, own client images, own validator sets, own lifecycle). -- **`local_testnet`** — hermetic, fully controlled; we own the whole validator set ⇒ **PTC duty hits every slot**. Already IMPLEMENTED + PROVEN. The fast inner-loop + CI signal — answers *"is our code correct?"*. -- **`devnet`** — the public ethpandaops multi-client net; **real 512-member PTC** against clients we don't control. The external-interop bar; needs infra (contract deploy + validators). Answers *"does it interop?"*. - -Do both: mock-green ≠ local-green ≠ interop-green. - -### Initiative `devnet` (Track 1) — public glamsterdam-devnet -1. **Devnet = devnet-6** (the live one as of 2026-06-30; devnet-5 now Off — **re-probe before each run**, see Gate check). **Sanity-check first:** `GET /eth/v1/config/spec` (confirm `GLOAS_FORK_EPOCH` is past the current epoch) + hit a PTC endpoint on a devnet-6 BN. Pull config (genesis time/root, fork schedule, chain ID, deposit contract, EL/BN endpoints + basic-auth) from `glamsterdam-devnets/network-configs/devnet-6/` + `config.glamsterdam-devnet-6.ethpandaops.io/api/v1/nodes/inventory`. - - **✅ Verified 2026-06-30:** devnet-6 live at epoch ~1118 (Gloas active ~1088 epochs). **Open read endpoints (no auth):** CL `https://beacon.glamsterdam-devnet-6.ethpandaops.io` (Prysm), EL `https://rpc.glamsterdam-devnet-6.ethpandaops.io` (chainId `0x1a462808d` = `7052886157`). **Per-client BNs** `bn---1.srv.glamsterdam-devnet-6.ethpandaops.io` (+ EL `rpc-…`) need **basic-auth** (401 without — creds from the ethpandaops devnet spec, or run your own / use the open Prysm BN). Spec matches the PR's wire values: `GLOAS_FORK_EPOCH:30`, `PTC_SIZE:512`, `PAYLOAD_ATTESTATION_DUE_BPS:7500` (=75% cutoff), `MAX_PAYLOAD_ATTESTATIONS:4`, domains `PTC_ATTESTER:0x0c`/`BEACON_BUILDER:0x0b`/`PROPOSER_PREFERENCES:0x0d`. `POST /eth/v1/validator/duties/ptc/{epoch}` → **200** (route live). **~3909 active validators** ⇒ PTC picks 512/slot, so each validator hits PTC ~every 8 slots (~90s) — frequent even with a handful of SSV validators (revises the earlier "PTC is rare on a shared net" caveat for devnet-6). -2. **`networkconfig` entry — committed + refreshed (2026-06-30):** `GlamsterdamDevnetSSV` in `networkconfig/glamsterdam-devnet.go` — an `&SSV{}` only (the Beacon side — genesis, fork schedule incl. `GLOAS_FORK_EPOCH` — comes from the BN at runtime; no `&Network{}` needed), registered + selectable as `glamsterdam-devnet` (domain `{0,0,9,0}`, `Boole:0`). Header now points at **devnet-6** (`7052886157` / `1782386940`) and `TotalEthereumValidators` is filled from the verified live count (**3909**). **Still `TODO(e2e)`** (devnet-specific, reset on every devnet; fill after steps 3-4): `RegistryContractAddr` + `RegistrySyncOffset` (contract deploy), `Bootnodes` (operator ENRs). -3. **Deploy SSV contracts** on the devnet EL; register 4 operators. -4. **Validators** — deposit via the devnet faucet/deposit contract → await activation → split keys into shares → register validators+shares on the SSV contract. -5. **Run 4 operators** (P1 image) on the devnet config; **assert the greppable operator logs** (`fetched PTC duties` → `successfully submitted payload attestation`) as the automatable pass/fail signal; the BN `payload_attestations` pool is a secondary on-chain cross-check. -- Risks: devnet resets/instability; validator activation latency; SSV contract deploy on a non-standard chain; per-client beacon-API PTC completeness (Lodestar/Lighthouse confirmed — verify the specific BN combo used). - -### Progress checklist -**`local_testnet` initiative:** ✅ **DONE** — automated Loki-based `(ptc)` suite **merged to aetheria `main` 2026-06-30** (#123 / #126 / #127 + ssv-mini#34 + ethereum2-monitor#504); #128 (kurtosis-native Loki) is the one open follow-up. Full checklist: [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125). - -**`devnet` initiative:** -- [x] **1 · Pick & verify the live devnet** — devnet-6 verified live 2026-06-30 (see step 1): epoch ~1118 (Gloas active), PTC route 200, open BN/EL endpoints recorded, spec wire-values match the PR. -- [x] **2 · `networkconfig` stub refreshed** — `glamsterdam-devnet.go` header → devnet-6 + `TotalEthereumValidators=3909` (verified). Remaining fields tracked in step 5. -- [ ] **3 · Deploy SSV contracts on the devnet-6 EL + register 4 operators** → record `RegistryContractAddr` + deployment block. -- [ ] **4 · Validators** — deposit (devnet deposit contract `0x00000000219ab540356cBB839Cbe05303d7705Fa`) → await activation → split keys into 4 shares → register validators+shares on the SSV contract. -- [ ] **5 · Fill remaining stub TODOs** — `RegistryContractAddr` + `RegistrySyncOffset` (from 3), `Bootnodes` (operator ENRs from 3). -- [ ] **6 · Run 4 operators** — `Network: glamsterdam-devnet`, `BeaconNodeAddr`=devnet-6 CL, `ETH1Addr`=devnet-6 EL (WS). -- [ ] **7 · Verify PTC** — grep all 4 operators: `Gloas (ePBS) fork scheduled` → `fetched PTC duties` → `✔️ successfully submitted payload attestation`; abstain only on missed slots; cross-check the BN `payload_attestations` pool. -- [ ] **8 · Confirm §2 aggregate payload-status index (review finding #4)** — `computeAttestationDataRoot` (`beacon/goclient/aggregator.go`) fetches the attestation data fresh from the aggregator's own BN and keeps that BN's payload-status index, not the QBFT-decided index the committee signed. Confirm the aggregate fetch matches the signed index across BNs (low risk — payload status should be settled by aggregation time — but a cross-BN mismatch would silently miss the aggregate). - -### `devnet` — operator run config (env vars; own EL/CL, no config file) -Config is `cleanenv`-based: a node started without `--config` reads purely from env (`ReadEnv`), and env overrides a file when one is passed — so the whole operator can be driven by env vars. Minimal set per operator (we run our own EL/CL): - -| Env var | Value / note | -|---|---| -| `NETWORK` | `glamsterdam-devnet` (selects `GlamsterdamDevnetSSV`; beacon genesis/fork schedule incl. `GLOAS_FORK_EPOCH` are read from the BN) | -| `BEACON_NODE_ADDR` | **required** — your CL HTTP URL(s); **Lighthouse/Lodestar preferred** for the full PTC endpoint set; `;`-separated for multiple | -| `ETH_1_ADDR` | **required** — your EL **WS** URL(s); `;`-separated for multiple | -| `OPERATOR_KEY` | this operator's private key (or `PRIVATE_KEY_FILE`). **Local signing only** — ssv-signer / Web3Signer PTC signing is unsupported (bounded by f), so don't use the remote-signer path for this test | -| `DB_PATH` | per-operator DB dir (default `./data/db`) | -| `LOG_LEVEL` | `debug` — the PTC/fork logs are the pass/fail signal | -| `NETWORK_PRIVATE_KEY` | optional P2P identity (auto-generated if unset); set one per operator to get stable ENRs for the stub's `Bootnodes` | -| `METRICS_API_PORT` / `EVENTS_PATH` | optional (metrics port; local-events injection) | - -Once the 4 nodes are up, harvest their ENRs into the stub `Bootnodes` (or pin a known `NETWORK_PRIVATE_KEY` per node) so the cluster discovers itself. - -### Initiative `local_testnet` (Track 2) — ssv-mini / aetheria local Gloas net — **MERGED to aetheria `main` 2026-06-30; automated `(ptc)` suite live (see result below). Tracking: [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125)** -**Correction:** the earlier claim that `ethpandaops/ethereum-package@6.1.0` "has no Gloas/Glamsterdam fork (only up to Fulu+BPO)" is **wrong**. 6.1.0's `network_params.yaml` ships `gloas_fork_epoch` (+ the §1 quarter-slot `*_due_bps_gloas` timings) and threads it through `input_parser → el_cl_genesis_generator → values.env.tmpl`; its own CI test `.github/tests/fulu-genesis.yaml` runs `fulu_fork_epoch: 0` + `gloas_fork_epoch: 2`. So a local Gloas net is configurable **today** — no upstream wait. The only real blocker was Gloas-capable client images, solved by the ethpandaops `glamsterdam-devnet-5` builds (all EL/CL clients tagged). - -Built across these PRs — **all merged 2026-06-30** (the local Gloas net reuses `local_testnet`'s on-chain identity — same contracts/validators — so no DB-seed duplication; Gloas is beacon-driven, read from the BN's `GLOAS_FORK_EPOCH`, so the SSV node needs no change). The automated assertion layer (Loki `(ptc)` suite #127, dbtest fail-fast #126) and the one remaining piece (#128) are tracked in [aetheria#125](https://github.com/ssvlabs/aetheria/issues/125): -1. **ssv-mini [#34](https://github.com/ssvlabs/ssv-mini/pull/34)** — `params-gloas.yaml` + `make run-gloas`: Fulu at genesis → Gloas at epoch 2, `glamsterdam-devnet-5` EL/CL images (Gloas-capable; the local net **pins these independently of whichever public devnet is live** — bump only if a later devnet build carries a client fix you need), genesis-generator pinned to `6.0.8` (6.1.0's default `5.3.5` predates Gloas), `boole_epoch: 0`. Usable standalone today for direct PTC observation (greppable SSV logs = the automatable signal; dora as a manual visual aid): `SSV_COMMIT=epbs-gloas make prepare && make run-gloas`. -2. **ethereum2-monitor [#504](https://github.com/ssvlabs/ethereum2-monitor/pull/504)** (scoped in #503) — Gloas block decoding (go-eth2-client v0.28.x can't decode Gloas): a reactive raw-JSON fallback in `beacon.FetchBlock` — no SSZ, no shared types. Re-enables E2M attestation validation on a Gloas chain. -3. **aetheria [#123](https://github.com/ssvlabs/aetheria/pull/123)** — a `local_testnet_gloas` network that routes to `params-gloas.yaml`, reusing local_testnet's identity; E2M capture made best-effort. `make run NETWORK=local_testnet_gloas TESTS='(event)'`. **Plus an E2M-coordination fix (2026-06-28, committed on `epbs/local-testnet-gloas`):** when `monitor-api` is absent the orchestrator now also sets the per-flow `e2m=false` (not just leaving `E2MURL` at a stale default), so the executor *skips* E2M and the `(event)` flow passes (on-chain lifecycle only) instead of hard-failing and tearing down. - -### Sequencing — the two initiatives run in parallel (no cross-dependency) -- **Shared foundation (done):** P1 image + the `networkconfig` stub — the *only* thing both initiatives share; after it they proceed independently. -- **`local_testnet` (DONE — keep green):** implemented + PROVEN (below). Re-run on each branch tip / in CI as the fast regression signal; owns its own client images + validator set. -- **`devnet` (infra, your hands — start in parallel):** devnet-6 probe + sanity check → SSV contract deploy + 4 operators → validators → fill the 3 stub TODOs → run + verify PTC via the greppable logs. Does **not** depend on `local_testnet`; can start any time. - -**`local_testnet` merge/enable order — ✅ completed (all merged 2026-06-30); kept for reference:** -1. **ssv-mini #34** — mergeable now; `make run-gloas` works standalone (monitor off; verify ePBS via greppable SSV logs — dora as a visual aid). Its `params-gloas.yaml` keeps `monitor.enabled: false` deliberately, so it's mergeable before E2M ships Gloas support. -2. **ethereum2-monitor #504** — merge; then rebuild the monitor image (ssv-mini `make prepare-monitor`, built from `../ethereum2-monitor`). -3. **ssv-mini follow-up** — once #504 is in the monitor image, flip `monitor.enabled: true` in `params-gloas.yaml`. This turns on E2M attestation validation on the Gloas chain. *(This is the easy-to-forget step — it's intentionally deferred out of #34 so #34 stays mergeable today.)* -4. **aetheria #123** — merge last; `local_testnet_gloas` then runs the full executor `(event)` flow with E2M. Its E2M capture is best-effort, so it also works between steps 1 and 3 — just without E2M validation until the monitor is re-enabled. - -Independent: aetheria #123 and ssv-mini #34 don't depend on #504 to *function* (E2M just stays skipped); #504 + step 3 only add E2M validation. The PTC/proposer/envelope ePBS behavior itself is verifiable from step 1 via greppable node logs (dora as a visual aid). - -### `local_testnet` — e2e RESULT (2026-06-28): full dormant → transition → executing PROVEN on the local Gloas net -Ran `aetheria local_testnet_gloas` end-to-end (host orchestrator + seeded DB + the `params-gloas.yaml` enclave, `node/ssv:epbs-gloas`, 4 operators). The SSV node's complete ePBS PTC lifecycle was observed live across the epoch-2 fork: -- **Dormant (epoch 0–1):** `DutyScheduler` starts `PTC_ATTESTER` + `PROPOSER_PREFERENCES`; they react to validator-index changes ("re-fetching PTC duties on next tick") but execute nothing. Boole active (`/ssv//boole/*` subnets). -- **Transition (epoch 2 / slot 64):** all 4 nodes' `PTC_ATTESTER` activates → `POST /eth/v1/validator/duties/ptc/2`. The first call at the exact fork-boundary slot returns CL `500 BeaconStateError(IncorrectStateVariant)` (lighthouse devnet-5: state not yet in the Gloas variant); the node's per-slot re-fetch **retries the next slot and succeeds**. Relevant to the `devnet` initiative too: expect a one-slot 500 at a node's first Gloas slot — the existing refetch absorbs it, no code change needed. -- **Executing (epoch 5 / slot 166):** with a validator held continuously active, `🔧 executing validator duty PTC_ATTESTER-e5-s166-v64` → `GET payload_attestation_data/166` → **`✔️ successfully submitted payload attestation` on all 4 operators**. An earlier duty at slot 148 correctly **failed-safe** — CL `404 No block received` on a missed slot (~80% block production on the devnet), so a validator's one-duty-per-epoch lands within an epoch or two; this is expected, not a node bug. - -**Two corrections to the prior handoff:** -1. **The "ENCRYPTION_KEY_HASH secret" blocker was a non-issue.** The working key (`SSV-AUTOMATION-…-KEY`) was already in the aetheria main checkout's `orchestrator/.env`; the stuck session was running from a different (`/tmp`) checkout whose `.env` carried the `aetheria-encryption-key` placeholder. No team secret is needed for the local Gloas run — config-gen decrypts the seed cleanly with the in-repo key (verified by decrypting the seed ciphertext directly and by a clean live config-gen). -2. **E2M coordination fix** added to #123 (see the #123 bullet above) — without it a plain `(event)` on Gloas false-fails at bulk E2M validation even though the on-chain + PTC behavior is correct. - -### PR #2855 (MEV timing games) — hold; merge ePBS first -**Decision:** do not merge #2855 for now — merge ePBS first, then reconsider #2855's role. - -Rationale: ePBS removes the *out-of-protocol* apparatus #2855's doc configures (mev-boost/commit-boost relay polling — gone post-Gloas), but **not** the underlying "select the bid as late as the deadline allows" dynamic. Post-Gloas that lever relocates: for a non-self-building proposer it moves *into SSV* (when it calls `GetGloasBeaconBlock`) and/or the BN's own bid selection, while the larger late-MEV lever moves to the builder (or SSV's §6 envelope timing when self-building). So #2855's SSV-side knob (`ProposalSoftDeadline`) likely **carries over with re-derived bounds** for the tighter 25%-of-slot (vs 33%) attestation deadline — a reduced job, not a removed one — which is what to re-evaluate once ePBS lands. - -- `ProposerDelay`: **keep, do not deprecate** — the live pre-Gloas MEV knob and, with #2855 on hold, the only one. Re-tune its role/bounds for ePBS rather than removing it. -- Docs: EXTERNAL_BUILDERS.md ePBS forward-pointer added (#2901); the MEV_CONSIDERATIONS.md full ePBS rewrite is deferred to the mainnet track. - -### ProposerDelay → ePBS split — DONE (#2901) -Fork-gated per-slot (`IsGloasAtSlot`): -- **Pre-ePBS:** `ProposerDelay` + `AllowDangerousProposerDelay` unchanged, but apply **pre-fork only** (today `ProposerDelay` fires under Gloas too — it sits before the `IsGloasAtSlot` branch in the proposer runner, so this is a real carve-out). -- **Post-ePBS:** `ProposerDelay`/`AllowDangerousProposerDelay` have no effect; a new **`ProposerDelayEPBS`** takes over with similar behavior, **hard-capped at 1000ms** (startup-rejected above it — no `AllowDangerous` override, for simplicity), **default 0** (opt-in). Tighter ~25% deadline + smaller/uncertain MEV upside ⇒ no aggressive escape hatch; default-off until live-devnet measurements justify a value. -- Self-document the flag in #2901 (config-struct comment + `config.example.yaml`); the MEV_CONSIDERATIONS.md prose update lands later in #2855 once its shape is final. -- Proposer budget under Gloas = `ProposerDelayEPBS` + the QBFT round timer (audit ① below). NOTE (corrected): the pre-Gloas `proposalSoftTimeout` does **not** apply under Gloas — the produce path uses `firstClientResult` and bypasses it (audit ④), so there's no proposalSoftTimeout↔delay coupling to tune. - -### Gloas timing audit — full inventory (thirds → quarters) -Every hardcoded slot-relative timeout/deadline classified so none is missed when `IntervalDuration` goes thirds→quarters. Scope: duty-execution + beacon-fetch + QBFT timing. (Out of scope by nature: p2p timeouts and message-validation lateness — those are slot-*count* / fixed-margin based, not interval-fraction, so the quarters change doesn't move them.) - -**Auto-scales via `IntervalDuration` — OK, no change:** aggregation fetch (`beacon/goclient/aggregator.go:95`), sync-contribution delay (`sync_committee_contribution.go:135`), scheduler exec delays (`operator/duties/scheduler.go:362,444`), indices-change deadlines (`proposer.go:121`, `attester.go:131`), 50% aggregator mark (`aggregator_committee.go:236`), `PayloadAttestationCutoff`=3/4 (`networkconfig/beacon.go:66`). - -**Hit-list — needs Gloas adjustment:** -1. **QBFT round timer — DONE (#2901).** `round1HeadStart` now derives from `IntervalDuration` (1× committee, 2× aggregator), so head starts track quarters under Gloas; `RoundTimeout`/`EstimatedRoundAt` (+ the message-validation `estimatedRoundAt`) pass `IntervalDuration(slot)`; pre-Gloas behavior is byte-identical (interval = `slotDuration/3`, verified by unchanged test expectations + a new Gloas case). **`QuickTimeout` kept at 2s** — it's a fixed round-trip budget, not a slot fraction — so the Gloas proposer is effectively round-1-must-succeed; shrinking it to restore the round-2 fallback is deferred to devnet RTT data (documented at the `QuickTimeout` const). (`QuickTimeoutThreshold`=8 / `SlowTimeout`=2min → leave.) -2. **`weightedAttestationData{Soft,Hard}Timeout` — DONE.** Scaled proportionally to the attestation window via a new `scaleToAttestationWindow(base, slot)` helper (integer `base * 3 / intervalsPerSlot`): unchanged pre-Gloas (1/3 window), ×3/4 from Gloas (1/4 window, ~4s→3s). Applied to the hard + soft fetch budgets and their soft/2 (scoring) + soft/4 (block-header) derivatives. Assumes BN response timings are fork-independent (the stated decision); nil-guarded for pre-init. -3. **Attestation refetch — DONE.** `minTimeForRetry`/`refetchDelay`/`refetchTimeout` scaled by the same `scaleToAttestationWindow` helper (the 100ms poll ticker is fork-agnostic granularity → left). - -**Checked — not applicable / corrected:** -4. **`proposalSoftTimeout`** (`options.go:47`, default 1800ms; min 500ms) — *not* a Gloas issue. The Gloas produce path uses `firstClientResult` (`gloas_proposer.go:28`) and bypasses it (pre-Gloas-only). The real Gloas question is a *design* one — should Gloas produce do multi-BN bid comparison / a soft timeout at all? — tied to `ProposerDelayEPBS`; forward-looking, not a constant to lower. -5. **§6 envelope / PTC / proposer-preferences runners — clean.** No hardcoded slot timing; all deadline-driven (deadline injected by the scheduler/executor). - -**Fork-agnostic — leave (verified):** `commonTimeout` 5s / `longTimeout` 60s (general HTTP); `ptcHTTPClient` has no client timeout — ctx-bounded (PTC fetches use `commonTimeout`, bounded by the slot-end duty deadline; Gloas produce/submit bounded by the proposer ctx); `blockPropagationDelay` 300ms (network propagation); scheduler `slotDelay≥100ms` drift threshold (L496/553); `attest.go:430` 100ms poll granularity; `observability.go:148` 1ms log threshold; queue micro-timings (`inboxReadFrequency` 1ms, `retryDelay` 25ms, ttlcache 10min, `SlotDuration/retryDelay` retry count); slotticker (SlotDuration boundary ticker); `DefaultSlotDuration` 12s. - ---- - -## §8 — Fork-transition monitoring + logs-first observability audit - -### Observability principle — logs-first (DEBUG-complete) -**Every ePBS behavior we care about MUST be verifiable from DEBUG logs alone.** OTel metrics are *nice-to-have* — dashboards/aggregation only, never the sole evidence a behavior happened (there are no in-repo dashboards anyway, and `local_testnet` was verified purely from logs). Rule: any metric that records an ePBS decision/outcome must have a matching log (DEBUG or higher) carrying the same fact. The audit below closes the cases where this doesn't yet hold. - -### Fork-transition monitoring — proven on `local_testnet_gloas`; re-apply on devnet-6 / Hoodi / Sepolia -The dormant→transition→executing flow is already PROVEN on the local Gloas net (the `local_testnet` RESULT above, epoch-2 fork, verified from logs). This is the generalized watch layer for any fork. - -**#1 blindspot:** the Gloas fork epoch is **not** in SSV config — it is read from each BN's `/eth/v1/config/spec` (`GLOAS_FORK_EPOCH`, `beacon/goclient/spec.go:259`), **with no startup log**. If a BN doesn't schedule it / BNs disagree, the node silently stays pre-Gloas (`IsGloas=false`, `IntervalDuration` stays /3) — no error, nothing ePBS fires. → pre-flight #1 + audit **G1**. - -**Control/scale per network:** `local_testnet_gloas` (the `local_testnet` initiative, DONE) sets the fork via `params-gloas.yaml` `gloas_fork_epoch: 2` — fully controllable. `glamsterdam-devnet-6` (the `devnet` initiative) — epoch from the BN, real 512-member PTC. Hoodi/Sepolia — unscheduled today (`FarFutureEpoch`); monitor-only once they schedule Gloas (same watch, no timing control). - -**Pre-flight (T-minus a few epochs):** (1) every BN's `GLOAS_FORK_EPOCH` equal + not far-future [the node can't self-check this — G1]; (2) every BN serves the 8 Gloas routes (block produce/publish, envelope get/publish, PTC duties/data/submit, proposer-dependent-root); (3) `ProposerDelayEPBS` ≤ 1s (else boot-abort); (4) validator set actually hits proposer + PTC selections in-window; (5) baseline pre-fork (`/3`, zero ePBS roles) for a clean delta. - -**Boundary quirks (from `local_testnet` — expect on any fork):** the first Gloas slot may return CL `500 BeaconStateError(IncorrectStateVariant)` → the per-slot refetch absorbs it (no code change); a missed proposal slot → CL `404 No block` → PTC fails-safe, the one-duty-per-epoch lands within an epoch or two. - -**Per-section primary watch** (log = source of truth; metric in parens): -- **§1 timing** — attestation submit rate holds across `/3→/4`; red flag: `⚠️ late duty execution` bursts (PTC lateness measured from the 75% cutoff). (`ssv.cl.request.duration{route=AttestationData}`, attestation refetch counters) -- **§2 attestation** — value-check `rejecting/ignoring invalid message` with `error=` (`AttestationDataIndex>1`, GloasBeaconVote 120B-vs-112B decode). (committee `duty.outcome`) -- **§3 PTC** — `fetched PTC duties` → `✔️ successfully submitted payload attestation`; `abstaining…no beacon block` occasional-ok / constant-bad; failures `failed to fetch PTC duties` / `PTC attestation failed…`. (`scheduler.executions{PTC_ATTESTER}`, `duty.outcome{PTC_ATTESTER}`) -- **§4 proposer** — `🧊 got gloas beacon block proposal` → `✅ successfully submitted block proposal`; build-source self/external [**G2 — log gap**]. (`proposal.build_source`, `submissions.failed{proposer}`) -- **§5 prefs** — `emitted proposer preferences duties` → `✔️ successfully submitted proposer preferences` (publish endpoint now live: `POST /eth/v1/validator/proposer_preferences`); red flag `could not submit proposer preferences` / `proposer preferences failed: could not build`. (`request{route=ProposerDutiesDependentRoot}`) -- **§6 envelope** — builder: [**G4 — produce log gap**] → `✅ published execution payload envelope`; non-builder: `this operator did not build the decided envelope, skipping publication`. (`request{route=*ExecutionPayloadEnvelope}`) -- **cross-role** — `⚠️ duty failed` / `⚠️ duty did not complete before slot end (likely stuck)`; succeeded/not_required [**G3 — log gap**]. (`ssv.runner.duty.outcome{role×outcome}` — the spine) - -### Log-coverage audit — G1–G4 + sweep DONE; G5 deferred -**Status (done):** G1–G4 shipped as a logs-only commit. Grep: `Gloas (ePBS) fork scheduled` (G1, Info) · `decided gloas block build source`+`self_build` (G2) · `duty concluded`+`outcome` (G3) · `built execution payload envelope` (G4). G5 (PTC non-convergence) deferred until gauged on devnet-6. Sweep DONE: §2 `built gloas attestation vote`+`payload_status_index`; §5 `built proposer preferences`+`dependent_root`/`fee_recipient`/`target_gas_limit`. -**Goal:** make the logs-first principle hold across #2901 — every metric-recorded or decision-point ePBS behavior gets a DEBUG+ log; metrics unchanged (viz only). -**Method:** per ePBS path (the new runners, duty handlers, goclient wrappers, value-checks, fork gates) enumerate behaviors/decisions/outcomes → confirm a DEBUG log carries each → where only a metric (or nothing) does, add a log. No behavior change; logs only. - -**Confirmed gaps + proposed logs:** -- **G1 — fork activation.** No log; `IsGloas` is computed from the BN's `GLOAS_FORK_EPOCH`. → startup INFO: resolved Gloas epoch + source BN (makes pre-flight #1 self-verifying); optional one-time "entered Gloas fork at slot N" at the boundary. -- **G2 — build source (self vs external builder).** Metric `ssv.runner.proposal.build_source` only; the `🧊` log lacks it. → DEBUG on each Gloas submit (the `selfBuild(block)` bit already at `proposer.go:~541`): "self-built block" vs "external builder N". *The key ePBS proposer signal.* -- **G3 — generic duty outcome succeeded/not_required.** `watchDutyOutcome.report` (`runner.go:~339`) records the metric for all four outcomes but logs only `failed`/`stuck` (Warn). → DEBUG "duty concluded" (outcome+role) for the non-warned outcomes → fully mirrors `ssv.runner.duty.outcome`. -- **G4 — envelope produce/cache.** `produceBlindedEnvelope` (`envelope.go:277`) fetches+caches the heavy envelope unlogged. → DEBUG "building execution payload envelope" (slot, block root, Took). -- **G5 — PTC non-convergence.** Surfaces only as the generic "likely stuck" (§7 obs note). → distinct DEBUG/marker once its frequency is gauged on devnet-6. - -**Sweep (done):** §2 chosen vote index (EMPTY=0/FULL=1) → `built gloas attestation vote`; proposer-preferences pinned values → `built proposer preferences`. Standing check: any other metric-only ePBS fact gets a matching log. -**NOT gaps (already DEBUG):** BN requests (`CL request done` + `route_name`), duty fetch/emit, `🔧 executing validator duty`, failures (Warn), abstain/skip, reorg-refresh. - -**Approach:** derive the hit-list fixes from `IntervalDuration`/the Gloas deadline (one fork-scaling source of truth), matching how the §1 deadlines already work. From f0e19678de9afceccaa1e8b795fa9e18f2f760ee Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 16:19:48 +0300 Subject: [PATCH 097/150] =?UTF-8?q?fix(gloas):=20key=20=C2=A74=20block=20s?= =?UTF-8?q?lashing=20protection=20to=20the=20signed=20block's=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gloas proposer path signed a block under block.Slot but keyed slashing protection to the plumbed duty slot, and the value check never bound the two. A malicious QBFT leader could decide a value whose block.Slot differed from cd.Duty.Slot, harvesting a validator signature over a block for a slot the slashing DB was not tracking — a cross-slot equivocation it would not catch. - value check: enforce block.Slot == cd.Duty.Slot on the Gloas proposer arm, and return the decoded block so CheckValue stops decoding it twice. - ekm: match a structural slashableBeaconBlock interface (fail loud on any other proposer object instead of blindly signing), key the check+record to the block's own slot, and guard the far-future bound locally — the plumbed slot no longer bounds it — mirroring the remote signer. Drop the now-unused plumbed slot from the local signer. - duties: downgrade the already-passed-slots proposer-fetch log from Warn to Debug (false-alarms on routine reorg / indices-change re-fetches). Adds regression tests for the slot-mismatch rejection, block-slot-keyed slashing protection, fail-loud on unknown objects, and the far-future guard. --- operator/duties/proposer.go | 5 +- protocol/v2/ssv/value_check.go | 50 ++++++++++-------- protocol/v2/ssv/value_check_test.go | 8 +++ protocol/v2/types/gloas/beacon_block.go | 4 ++ ssvsigner/ekm/local_key_manager.go | 44 +++++++++++----- ssvsigner/ekm/local_key_manager_test.go | 69 ++++++++++++++++++++----- 6 files changed, 131 insertions(+), 49 deletions(-) diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index 18d189a98d..fb9d07c71a 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -528,9 +528,10 @@ func (h *ProposerHandler) logFetchDispatchDiagnostic(logger *zap.Logger, targetE ) } - // Fetched in-committee duties for slots that already passed — a guaranteed miss (fetched too late). + // Fetched in-committee duties for slots that already passed — a guaranteed miss only on a first fetch; + // on routine re-fetches (reorg, validator-indices change) it is expected, so log at Debug, not Warn. if len(alreadyPassed) > 0 { - logger.Warn("🔬 proposer fetch: in-committee duties for already-passed slots (diagnostic)", + logger.Debug("🔬 proposer fetch: in-committee duties for already-passed slots (diagnostic)", zap.Uint64("target_epoch", uint64(targetEpoch)), zap.Uint64("current_slot", uint64(currentSlot)), zap.Int("in_committee_total", inCommittee), diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index bbb51d675a..a3b0373e4d 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -306,20 +306,16 @@ func NewProposerChecker( } func (v *proposerChecker) CheckValue(value []byte) error { - cd, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleProposer, v.validatorPK, v.validatorIndex) + cd, gloasBlock, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleProposer, v.validatorPK, v.validatorIndex) if err != nil { return err } var slot phase0.Slot - if v.beaconConfig.IsGloasAtSlot(cd.Duty.Slot) { - // Gloas blocks have no spectypes block version; GetBlockData can't decode them, so read the - // slot from the node-side block directly. - block, decErr := gloas.DecodeBeaconBlock(cd.DataSSZ) - if decErr != nil { - return fmt.Errorf("could not decode gloas block: %w", decErr) - } - slot = block.Slot + if gloasBlock != nil { + // Gloas blocks have no spectypes block version; checkValidatorConsensusData already decoded the + // node-side block and verified block.Slot == duty slot, so reuse it rather than decode again. + slot = gloasBlock.Slot } else { blockData, _, bdErr := cd.GetBlockData() if bdErr != nil { @@ -352,7 +348,7 @@ func NewAggregatorChecker( } func (v *aggregatorChecker) CheckValue(value []byte) error { - _, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleAggregator, v.validatorPK, v.validatorIndex) + _, _, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleAggregator, v.validatorPK, v.validatorIndex) return err } @@ -375,47 +371,59 @@ func NewSyncCommitteeContributionChecker( } func (v *syncCommitteeContributionChecker) CheckValue(value []byte) error { - _, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleSyncCommitteeContribution, v.validatorPK, v.validatorIndex) + _, _, err := checkValidatorConsensusData(value, v.beaconConfig, spectypes.BNRoleSyncCommitteeContribution, v.validatorPK, v.validatorIndex) return err } +// checkValidatorConsensusData decodes and validates a ProposerConsensusData value. On the Gloas +// proposer path it also decodes the node-side block and returns it (nil otherwise) so callers reuse it +// instead of decoding the ~MB block a second time. func checkValidatorConsensusData( value []byte, beaconConfig *networkconfig.Beacon, expectedType spectypes.BeaconRole, validatorPK spectypes.ValidatorPK, validatorIndex phase0.ValidatorIndex, -) (*spectypes.ProposerConsensusData, error) { +) (*spectypes.ProposerConsensusData, *gloas.BeaconBlock, error) { cd := &spectypes.ProposerConsensusData{} if err := cd.Decode(value); err != nil { - return nil, fmt.Errorf("failed decoding consensus data: %w", err) + return nil, nil, fmt.Errorf("failed decoding consensus data: %w", err) } + var gloasBlock *gloas.BeaconBlock if cd.Duty.Type == spectypes.BNRoleProposer && beaconConfig.IsGloasAtSlot(cd.Duty.Slot) { // Gloas blocks have no spectypes block version, so ValidateConsensusData's GetBlockData path // can't decode them; a successful node-side decode is the validity check. - if _, err := gloas.DecodeBeaconBlock(cd.DataSSZ); err != nil { - return cd, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") + block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) + if err != nil { + return cd, nil, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") + } + // Pin the block's own slot to the duty slot: the block is signed under block.Slot and slashing + // protection keys on it, so a leader that decoupled the two could harvest a signature for another + // slot — an equivocation the slashing DB would miss. Also bounds block.Slot to the far-future check. + if block.Slot != cd.Duty.Slot { + return cd, nil, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "gloas block slot does not match duty slot") } + gloasBlock = block } else if err := ssvtypes.ValidateConsensusData(cd); err != nil { - return cd, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") + return cd, nil, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") } if expectedType != cd.Duty.Type { - return cd, spectypes.NewError(spectypes.WrongBeaconRoleTypeErrorCode, "wrong beacon role type") + return cd, nil, spectypes.NewError(spectypes.WrongBeaconRoleTypeErrorCode, "wrong beacon role type") } if beaconConfig.EstimatedEpochAtSlot(cd.Duty.Slot) > beaconConfig.EstimatedCurrentEpoch()+1 { - return cd, spectypes.NewError(spectypes.DutyEpochTooFarFutureErrorCode, "duty epoch is into far future") + return cd, nil, spectypes.NewError(spectypes.DutyEpochTooFarFutureErrorCode, "duty epoch is into far future") } if !bytes.Equal(validatorPK[:], cd.Duty.PubKey[:]) { - return cd, spectypes.NewError(spectypes.WrongValidatorPubkeyErrorCode, "wrong validator pk") + return cd, nil, spectypes.NewError(spectypes.WrongValidatorPubkeyErrorCode, "wrong validator pk") } if validatorIndex != cd.Duty.ValidatorIndex { - return cd, spectypes.NewError(spectypes.WrongValidatorIndexErrorCode, "wrong validator index") + return cd, nil, spectypes.NewError(spectypes.WrongValidatorIndexErrorCode, "wrong validator index") } - return cd, nil + return cd, gloasBlock, nil } diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 9328cd7390..1e4f493059 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -244,6 +244,14 @@ func TestProposerChecker_GloasDecodeError(t *testing.T) { require.Error(t, checker.CheckValue(gloasProposerConsensusData(t, []byte{0x00, 0x01, 0x02}))) } +// A block whose own slot differs from the duty slot is rejected: the anti-harvest guard in +// checkValidatorConsensusData (SIP #94 §4). +func TestProposerChecker_GloasBlockSlotMismatch(t *testing.T) { + checker := newGloasProposerChecker(fakeSlashingSigner{}) + err := checker.CheckValue(gloasProposerConsensusData(t, gloasBlockSSZ(t, gloasProposerSlot+1))) + require.ErrorContains(t, err, "does not match duty slot") +} + // --- envelope checker, §6 --- var envelopeValidatorPK = phase0.BLSPubKey{0x42} diff --git a/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go index 595143189b..9eeae3181c 100644 --- a/protocol/v2/types/gloas/beacon_block.go +++ b/protocol/v2/types/gloas/beacon_block.go @@ -64,6 +64,10 @@ type SignedBeaconBlock struct { func (b *BeaconBlock) Encode() ([]byte, error) { return b.MarshalSSZ() } func (b *BeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } +// BlockSlot exposes the slot through a method so the ekm — a separate module that can't import +// *gloas.BeaconBlock — can read it via a structural interface to key block slashing protection. +func (b *BeaconBlock) BlockSlot() phase0.Slot { return b.Slot } + func (b *SignedBeaconBlock) Encode() ([]byte, error) { return b.MarshalSSZ() } func (b *SignedBeaconBlock) Decode(data []byte) error { return b.UnmarshalSSZ(data) } diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index e1753b8a49..ad034ef47b 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -55,6 +55,7 @@ type LocalKeyManager struct { signer signer.ValidatorSigner operatorDecrypter keys.OperatorDecrypter slashingProtector slashingProtector + beaconConfig BeaconNetwork // blockProposalLock serializes the manual slashing check→record→sign for Gloas blocks, which can't go // through the lib's atomic SignBeaconBlock (see signBeaconObject's Gloas case). walletLock is only @@ -62,6 +63,14 @@ type LocalKeyManager struct { blockProposalLock sync.Mutex } +// slashableBeaconBlock is the structural view the signer needs of a Gloas (ePBS) block: its SSZ signing +// root (to sign) plus its own slot (to key slashing protection). *gloas.BeaconBlock satisfies it, so the +// signer reads the slot without importing the node-side type across the module boundary. +type slashableBeaconBlock interface { + ssz.HashRoot + BlockSlot() phase0.Slot +} + // NewLocalKeyManager returns a new LocalKeyManager. func NewLocalKeyManager( logger *zap.Logger, @@ -107,6 +116,7 @@ func NewLocalKeyManager( signer: beaconSigner, slashingProtector: NewSlashingProtector(logger, beacon, signerStore, protection), operatorDecrypter: operatorPrivKey, + beaconConfig: beacon, }, nil } @@ -121,10 +131,12 @@ func (km *LocalKeyManager) SignBeaconObject( obj ssz.HashRoot, domain phase0.Domain, pubKey phase0.BLSPubKey, - slot phase0.Slot, + _ phase0.Slot, signatureDomain phase0.DomainType, ) (spectypes.Signature, phase0.Root, error) { - sig, rootSlice, err := km.signBeaconObject(obj, domain, pubKey, slot, signatureDomain) + // slot is unused: the local signer derives the proposal slot it protects from the block object itself + // (see signBeaconObject's Gloas case); the other domains don't need it. + sig, rootSlice, err := km.signBeaconObject(obj, domain, pubKey, signatureDomain) if err != nil { return nil, phase0.Root{}, err } @@ -137,7 +149,6 @@ func (km *LocalKeyManager) signBeaconObject( obj ssz.HashRoot, domain phase0.Domain, pubKey phase0.BLSPubKey, - slot phase0.Slot, signatureDomain phase0.DomainType, ) (spectypes.Signature, []byte, error) { km.walletLock.RLock() @@ -197,20 +208,27 @@ func (km *LocalKeyManager) signBeaconObject( default: // Gloas (ePBS) block: go-eth2-client has no Gloas VersionedBeaconBlock for the slashing- // protected SignBeaconBlock path, and ssvsigner (a separate module) can't import the node's - // *gloas.BeaconBlock to type-switch on it. The decided block arrives as an ssz.HashRoot, so - // sign its SSZ signing root directly. - // - // A block proposal IS slashable (unlike the other Gloas domains, which signSSZRoot handles - // unguarded), and signSSZRoot doesn't protect it — so replicate what the lib's SignBeaconBlock - // does internally: check + record the highest proposal, then sign. Use the plumbed-through slot, - // since we can't read it off the opaque block. blockProposalLock makes the check→record→sign - // atomic (walletLock is only RLocked here). Mirrors the remote handleDomainProposer. + // *gloas.BeaconBlock to type-switch on it. Match the structural slashableBeaconBlock interface + // instead; anything else reaching this arm is a routing bug, so fail loud rather than sign it. + block, ok := obj.(slashableBeaconBlock) + if !ok { + return nil, nil, fmt.Errorf("unexpected object type for proposer domain: %T", obj) + } + // A block proposal is slashable but signSSZRoot doesn't protect it, so replicate the lib's + // SignBeaconBlock: check + record the highest proposal, then sign. Key it to the block's own + // slot (what we sign), not the plumbed duty slot, so the slashing DB reflects the signed content + // — and guard the far-future bound here, since the plumbed slot no longer does. blockProposalLock + // makes check→record→sign atomic (walletLock is only RLocked). Mirrors the remote handleDomainProposer. + blockSlot := block.BlockSlot() + if !signer.IsValidFarFutureSlot(km.beaconConfig, blockSlot) { + return nil, nil, fmt.Errorf("proposed block slot too far into the future") + } km.blockProposalLock.Lock() defer km.blockProposalLock.Unlock() - if err := km.slashingProtector.IsBeaconBlockSlashable(pubKey, slot); err != nil { + if err := km.slashingProtector.IsBeaconBlockSlashable(pubKey, blockSlot); err != nil { return nil, nil, err } - if err := km.slashingProtector.UpdateHighestProposal(pubKey, slot); err != nil { + if err := km.slashingProtector.UpdateHighestProposal(pubKey, blockSlot); err != nil { return nil, nil, err } return signSSZRoot(km.signer, obj, domain, pubKey[:]) diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 19008076cb..69d7ee336c 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -2,6 +2,7 @@ package ekm import ( "encoding/hex" + "math" "testing" "time" @@ -337,9 +338,17 @@ func TestSignBeaconObject(t *testing.T) { } } -func TestSignBeaconObjectGloasBlockSlashingProtection(t *testing.T) { - ctx := t.Context() +// gloasBlockStub stands in for *gloas.BeaconBlock in these tests. The ssvsigner module can't import the +// node-side gloas package, so we exercise signBeaconObject's slashableBeaconBlock path with a type that, +// like the real block, is HTR-able (via the embedded header) and exposes its own slot through BlockSlot. +type gloasBlockStub struct { + *phase0.BeaconBlockHeader +} +func (b gloasBlockStub) BlockSlot() phase0.Slot { return b.BeaconBlockHeader.Slot } + +// newLocalKeyManagerWithShare returns a LocalKeyManager with sk1 added as a share, plus its public key. +func newLocalKeyManagerWithShare(t *testing.T) (*LocalKeyManager, phase0.BLSPubKey) { operatorPrivateKey, err := keys.GeneratePrivateKey() require.NoError(t, err) km := testKeyManager(t, operatorPrivateKey) @@ -349,28 +358,62 @@ func TestSignBeaconObjectGloasBlockSlashingProtection(t *testing.T) { encryptedSK1, err := operatorPrivateKey.Public().Encrypt([]byte(sk1.SerializeToHexStr())) require.NoError(t, err) pk := phase0.BLSPubKey(sk1.GetPublicKey().Serialize()) - require.NoError(t, km.AddShare(ctx, nil, encryptedSK1, pk)) + require.NoError(t, km.AddShare(t.Context(), nil, encryptedSK1, pk)) - lkm := km.(*LocalKeyManager) + return km.(*LocalKeyManager), pk +} - // A Gloas block reaches signBeaconObject's default case (ssvsigner can't name *gloas.BeaconBlock); any - // ssz.HashRoot that isn't a known go-eth2-client block type exercises it — a header is a fine stand-in. - // That path used to signSSZRoot with no slashing protection; the guard must now reject a re-proposal. - proposalSlot := testBeaconConfig().EstimatedCurrentSlot() + minSPProposalSlotGap + 10 - block := &phase0.BeaconBlockHeader{Slot: proposalSlot} +func TestSignBeaconObjectGloasBlockSlashingProtection(t *testing.T) { + ctx := t.Context() + lkm, pk := newLocalKeyManagerWithShare(t) - // First proposal: signs and records the highest proposal. - _, root, err := lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, proposalSlot, spectypes.DomainProposer) + // A Gloas block reaches signBeaconObject's default case via the slashableBeaconBlock interface. That + // path must key slashing protection to the block's OWN slot, not the plumbed duty slot, so pass a + // different plumbed slot and assert the recorded highest proposal is the block's. + blockSlot := testBeaconConfig().EstimatedCurrentSlot() + minSPProposalSlotGap + 10 + block := gloasBlockStub{&phase0.BeaconBlockHeader{Slot: blockSlot}} + plumbedSlot := blockSlot - 5 // deliberately different; the Gloas arm must ignore it + + // First proposal signs and records the highest proposal keyed to the block's own slot. + _, root, err := lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, plumbedSlot, spectypes.DomainProposer) require.NoError(t, err) require.NotEqual(t, phase0.Root{}, root) - // Re-proposing the same slot is slashable → rejected (proves the highest-proposal record + the + highest, found, err := lkm.RetrieveHighestProposal(pk) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, blockSlot, highest, "slashing protection must record the block's own slot, not the plumbed slot") + + // Re-proposing the same block slot is slashable → rejected (proves the highest-proposal record + the // IsBeaconBlockSlashable guard that the direct signSSZRoot path used to skip). - _, _, err = lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, proposalSlot, spectypes.DomainProposer) + _, _, err = lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, plumbedSlot, spectypes.DomainProposer) require.Error(t, err) require.Contains(t, err.Error(), "slashable") } +func TestSignBeaconObjectGloasRejectsUnknownProposerObject(t *testing.T) { + ctx := t.Context() + lkm, pk := newLocalKeyManagerWithShare(t) + + // A proposer-domain object that is neither a known go-eth2-client block nor a slashableBeaconBlock is a + // routing bug: the signer must fail loud rather than sign an unrecognized object without slashing + // protection. A bare header (no BlockSlot method) does not satisfy the interface. + slot := testBeaconConfig().EstimatedCurrentSlot() + minSPProposalSlotGap + 10 + _, _, err := lkm.SignBeaconObject(ctx, &phase0.BeaconBlockHeader{Slot: slot}, phase0.Domain{}, pk, slot, spectypes.DomainProposer) + require.ErrorContains(t, err, "unexpected object type") +} + +func TestSignBeaconObjectGloasRejectsFarFutureSlot(t *testing.T) { + ctx := t.Context() + lkm, pk := newLocalKeyManagerWithShare(t) + + // Keying slashing protection to the block's own slot means the Gloas arm must guard the far-future + // bound itself (the plumbed slot is no longer the bound). An absurd block slot is rejected before signing. + block := gloasBlockStub{&phase0.BeaconBlockHeader{Slot: math.MaxUint64 / 2}} + _, _, err := lkm.SignBeaconObject(ctx, block, phase0.Domain{}, pk, testBeaconConfig().EstimatedCurrentSlot(), spectypes.DomainProposer) + require.ErrorContains(t, err, "too far into the future") +} + func TestRemoveShare(t *testing.T) { require.NoError(t, bls.Init(bls.BLS12_381)) From de9f5bcf96e53c2b923c503369b6582b0ec46e7a Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 16:23:05 +0300 Subject: [PATCH 098/150] gloas: export GloasDataVersion and guard its mirror of DataVersionGloas ekm.GloasDataVersion and networkconfig.DataVersionGloas are hand-kept mirrors across the module boundary (ssvsigner has its own go.mod and can't import the node placeholder). Export the ekm constant and add a node-side test asserting the two stay equal, so a future change to one can't silently desync the remote signer's Gloas fork/domain resolution. --- protocol/v2/ssv/value_check_test.go | 7 +++++++ ssvsigner/ekm/remote_key_manager.go | 9 +++++---- ssvsigner/ekm/remote_key_manager_test.go | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 1e4f493059..a0576cc9c1 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -321,3 +321,10 @@ func TestEnvelopeChecker_DecodeError(t *testing.T) { checker := newEnvelopeCheckerWithRoot(7, phase0.Root{0xaa}) require.Error(t, checker.CheckValue([]byte{0x00, 0x01})) } + +// ekm.GloasDataVersion is a hand-kept mirror of networkconfig.DataVersionGloas (the ssvsigner module has +// its own go.mod and can't import networkconfig). If they drift, the remote signer resolves the wrong +// fork/domain on Gloas slots, so guard the mirror here on the node side, where both are importable. +func TestGloasDataVersionMirror(t *testing.T) { + require.Equal(t, networkconfig.DataVersionGloas, ekm.GloasDataVersion) +} diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index bd5a4c4e0b..d0fcd0d90c 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -490,9 +490,10 @@ func (km *RemoteKeyManager) handleDomainProposer( return ret, nil } -// gloasDataVersion mirrors networkconfig.DataVersionGloas — the ssvsigner module has its own go.mod and -// can't import the node-side placeholder. Remove once go-eth2-client ships a real spec.DataVersionGloas. -const gloasDataVersion = spec.DataVersionFulu + 1 +// GloasDataVersion mirrors networkconfig.DataVersionGloas — the ssvsigner module has its own go.mod and +// can't import the node-side placeholder, so a node-side test asserts the two stay equal. Remove once +// go-eth2-client ships a real spec.DataVersionGloas. +const GloasDataVersion = spec.DataVersionFulu + 1 // GetForkInfo returns the ForkInfo for the epoch's active fork. Web3Signer derives the signing domain // from it, so on a Gloas epoch it must carry the Gloas fork: ForkAtEpoch's version list caps at Fulu @@ -500,7 +501,7 @@ const gloasDataVersion = spec.DataVersionFulu + 1 // duty under the wrong (Fulu) domain. Substitute the Gloas fork when it is configured and active. func (km *RemoteKeyManager) GetForkInfo(epoch phase0.Epoch) web3signer.ForkInfo { _, currentFork := km.beaconConfig.ForkAtEpoch(epoch) - if gloasFork, ok := km.beaconConfig.ForkAtVersion(gloasDataVersion); ok && epoch >= gloasFork.Epoch { + if gloasFork, ok := km.beaconConfig.ForkAtVersion(GloasDataVersion); ok && epoch >= gloasFork.Epoch { currentFork = &gloasFork } diff --git a/ssvsigner/ekm/remote_key_manager_test.go b/ssvsigner/ekm/remote_key_manager_test.go index 00345fc90e..c4b45b7751 100644 --- a/ssvsigner/ekm/remote_key_manager_test.go +++ b/ssvsigner/ekm/remote_key_manager_test.go @@ -1581,7 +1581,7 @@ func (s *RemoteKeyManagerTestSuite) TestGetForkInfoUsesGloasForkOnGloasEpoch() { cfg := testBeaconConfig() const gloasEpoch = phase0.Epoch(7) gloasVersion := phase0.Version{7, 0, 0, 0} - cfg.Forks[gloasDataVersion] = phase0.Fork{ + cfg.Forks[GloasDataVersion] = phase0.Fork{ Epoch: gloasEpoch, PreviousVersion: phase0.Version{6, 0, 0, 0}, CurrentVersion: gloasVersion, From c2a5fd56a42532a0556212438e736734588f5dce Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 16:54:37 +0300 Subject: [PATCH 099/150] gloas: convert proposer-duty TEMP diagnostics to permanent Debug logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three proposer-duty diagnostics were tagged TEMP for the devnet "missed proposals" investigation (#2920). Make them permanent, lean Debug telemetry rather than merge TEMP scaffolding: drop the TEMP(#2901) tags, the 🔬 / "(diagnostic)" markers and the per-share sample dump in logNoEligible (count-only now), rename off the "Diagnostic" suffix, and tighten the docs. No behavior change beyond the dropped sample dump. --- operator/duties/proposer.go | 78 ++++++++++++++----------------------- 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index fb9d07c71a..39fe38c701 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -313,8 +313,7 @@ func (h *ProposerHandler) processExecution(ctx context.Context, epoch phase0.Epo duties := h.duties.CommitteeSlotDuties(epoch, slot) - // TEMP(ssvlabs/ssv#2901): per-slot dispatch diagnostic — remove after devnet confirmation. - h.logSlotDispatchDiagnostic(epoch, slot, duties) + h.logProposerSlotDispatch(epoch, slot, duties) if duties == nil { span.AddEvent("no duties available") @@ -365,7 +364,7 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap } if len(allEligibleIndices) == 0 { const eventMsg = "no eligible validators for epoch" - h.logNoEligibleDiagnostic(logger, targetEpoch) // TEMP(ssvlabs/ssv#2901): remove after devnet confirmation + h.logNoEligibleValidators(logger, targetEpoch) span.AddEvent(eventMsg) span.SetStatus(codes.Ok, "") // No eligible validators yet — not a fulfilled fetch; caller retries on a later tick. @@ -402,8 +401,7 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap span.AddEvent("storing duties", trace.WithAttributes(observability.DutyCountAttribute(len(storeDuties)))) h.duties.Set(targetEpoch, storeDuties) - // TEMP(ssvlabs/ssv#2901): late-fetch / InCommittee-on-success diagnostic — remove after devnet confirmation. - h.logFetchDispatchDiagnostic(logger, targetEpoch, currentSlot, storeDuties) + h.logProposerFetchOutcome(logger, targetEpoch, currentSlot, storeDuties) truncate := -1 if h.exporterMode { @@ -421,12 +419,11 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap return true, nil } -// logNoEligibleDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901) for the "every proposer slot missed -// on the Gloas devnet" investigation. On zero eligible validators it dumps Validators() vs SelfValidators() -// so a devnet run can distinguish metadata-not-synced-yet (shares present but IsAttesting=false; the retry -// fix recovers these) from a diverging/empty Validators() view (self_attesting>0 yet none eligible; a -// different root cause the retry would not fix). Remove once the root cause is confirmed on devnet. -func (h *ProposerHandler) logNoEligibleDiagnostic(logger *zap.Logger, targetEpoch phase0.Epoch) { +// logNoEligibleValidators logs (Debug) the validator-set counts behind a "no eligible validators for +// epoch" outcome — total known validators vs this node's own, and how many of those are attesting — to +// tell "shares present but metadata not synced yet" (recovers on retry) apart from "this node has +// attesting validators yet none are eligible" (a different cause the retry would not fix). +func (h *ProposerHandler) logNoEligibleValidators(logger *zap.Logger, targetEpoch phase0.Epoch) { all := h.validatorProvider.Validators() self := h.validatorProvider.SelfValidators() @@ -437,46 +434,31 @@ func (h *ProposerHandler) logNoEligibleDiagnostic(logger *zap.Logger, targetEpoc } } - const sampleCap = 16 - samples := make([]string, 0, min(len(all), sampleCap)) - for i, s := range all { - if i >= sampleCap { - break - } - samples = append(samples, fmt.Sprintf("idx=%d status=%s hasMeta=%t attesting=%t liquidated=%t", - s.ValidatorIndex, s.Status, s.HasBeaconMetadata(), s.IsAttesting(targetEpoch), s.Liquidated)) - } - - logger.Debug("🔬 no eligible validators for epoch (diagnostic)", + logger.Debug("no eligible validators for epoch", zap.Uint64("target_epoch", uint64(targetEpoch)), zap.Int("validators_total", len(all)), zap.Int("self_validators", len(self)), zap.Int("self_attesting", selfAttesting), - zap.Strings("shares", samples), ) } -// logSlotDispatchDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901) for the "every proposer slot -// missed on the Gloas devnet" investigation. On any slot for which this node holds a stored proposer duty -// it records how the duty flows through the two execution gates — InCommittee (CommitteeSlotDuties) and -// shouldExecute (the one-slot execution window) — so a devnet run can tell apart the candidate causes of a -// missed proposal without guessing: +// logProposerSlotDispatch logs (Debug) how this node's stored proposer duty for a slot flows through the +// two execution gates — InCommittee (CommitteeSlotDuties) and shouldExecute's one-slot window: // -// stored_any>0, in_committee=0 → stored but dropped by the InCommittee flag (the PR-comment hypothesis) -// in_committee>0, executable=0 → in-committee but outside the one-slot window (resolved/fetched too late) -// executable>0 → dispatched to the runner; any remaining loss is downstream (see the -// "🔧 executing validator duty" / "could not find validator" logs) +// stored_any>0, in_committee=0 → stored but dropped by the InCommittee flag +// in_committee>0, executable=0 → in-committee but outside the one-slot window (resolved/fetched too late) +// executable>0 → dispatched to the runner (any further loss is downstream) // -// It fires only on slots that actually carry a stored duty (SlotIndices short-circuits otherwise), so it is -// not per-slot noise. Remove once the root cause is confirmed on devnet. -func (h *ProposerHandler) logSlotDispatchDiagnostic(epoch phase0.Epoch, slot phase0.Slot, inCommittee []*eth2apiv1.ProposerDuty) { +// It fires only on slots that carry a stored duty (SlotIndices short-circuits otherwise), so it is not +// per-slot noise. +func (h *ProposerHandler) logProposerSlotDispatch(epoch phase0.Epoch, slot phase0.Slot, inCommittee []*eth2apiv1.ProposerDuty) { storedAny := h.duties.SlotIndices(epoch, slot) if len(storedAny) == 0 { - return // no proposer duty stored for this slot — nothing was assigned to us here, nothing to diagnose + return // no proposer duty stored for this slot — nothing was assigned to us here } // Mirror shouldExecute's window (currentSlot == or +1 == duty.Slot) WITHOUT its warnMisalignedSlotAndDuty - // side effect, so the diagnostic never double-logs the misalignment warning the real dispatch loop emits. + // side effect, so this never double-logs the misalignment warning the real dispatch loop emits. currentSlot := h.netCfg.EstimatedCurrentSlot() executable := 0 for _, d := range inCommittee { @@ -490,7 +472,7 @@ func (h *ProposerHandler) logSlotDispatchDiagnostic(epoch phase0.Epoch, slot pha storedIdx[i] = uint64(idx) } - h.logger.Debug("🔬 proposer slot dispatch (diagnostic)", + h.logger.Debug("proposer slot dispatch", zap.Uint64("epoch", uint64(epoch)), zap.Uint64("slot", uint64(slot)), zap.Uint64("current_slot", uint64(currentSlot)), @@ -501,13 +483,11 @@ func (h *ProposerHandler) logSlotDispatchDiagnostic(epoch phase0.Epoch, slot pha ) } -// logFetchDispatchDiagnostic is a TEMPORARY diagnostic (ssvlabs/ssv#2901). Right after a fetch stores an -// epoch's proposer duties it reports, for THIS node's in-committee duties, how many are for slots that have -// already passed at fetch time (current_slot) — proposals whose one-slot execution window is already gone -// (the "fetched too late" failure the retry fix does not address). It also surfaces the InCommittee split on -// the fetch-SUCCESS path, which logNoEligibleDiagnostic (zero-eligible only) cannot see — so a run can -// directly confirm or refute InCommittee=0 at loaded epochs. Remove once the root cause is confirmed. -func (h *ProposerHandler) logFetchDispatchDiagnostic(logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot, stored []dutystore.StoreDuty[eth2apiv1.ProposerDuty]) { +// logProposerFetchOutcome logs (Debug), right after a fetch stores an epoch's proposer duties, two +// outcomes worth tracing: duties were stored but none are this node's (in_committee=0), and in-committee +// duties for slots that already passed at fetch time — a guaranteed miss on a first fetch, benign on +// routine reorg / validator-indices re-fetches. +func (h *ProposerHandler) logProposerFetchOutcome(logger *zap.Logger, targetEpoch phase0.Epoch, currentSlot phase0.Slot, stored []dutystore.StoreDuty[eth2apiv1.ProposerDuty]) { inCommittee := 0 alreadyPassed := make([]uint64, 0) for _, d := range stored { @@ -520,18 +500,18 @@ func (h *ProposerHandler) logFetchDispatchDiagnostic(logger *zap.Logger, targetE } } - // Fetched some duties, but none belong to this node — the InCommittee=0 case the PR comment posits. + // Fetched some duties, but none belong to this node. if len(stored) > 0 && inCommittee == 0 { - logger.Debug("🔬 proposer fetch: stored duties but none in-committee (diagnostic)", + logger.Debug("proposer fetch: stored duties but none in-committee", zap.Uint64("target_epoch", uint64(targetEpoch)), zap.Int("stored_total", len(stored)), ) } // Fetched in-committee duties for slots that already passed — a guaranteed miss only on a first fetch; - // on routine re-fetches (reorg, validator-indices change) it is expected, so log at Debug, not Warn. + // on routine re-fetches (reorg, validator-indices change) it is expected, hence Debug, not Warn. if len(alreadyPassed) > 0 { - logger.Debug("🔬 proposer fetch: in-committee duties for already-passed slots (diagnostic)", + logger.Debug("proposer fetch: in-committee duties for already-passed slots", zap.Uint64("target_epoch", uint64(targetEpoch)), zap.Uint64("current_slot", uint64(currentSlot)), zap.Int("in_committee_total", inCommittee), From 1a8809a8ddee6580fcbe9080e41695e1cb26e10c Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 16:54:39 +0300 Subject: [PATCH 100/150] gloas: decode proposer dependent_root directly into phase0.Root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase0.Root.UnmarshalJSON already parses and length-checks the "0x…" hex, so type the response field as phase0.Root instead of hand-rolling hex.DecodeString + TrimPrefix + length check. Drops the manual parse (and the hex/strings imports) and additionally rejects a missing 0x prefix the old code tolerated. --- beacon/goclient/proposer_preferences.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index f1b3755582..4fb3cdb1da 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -2,11 +2,9 @@ package goclient import ( "context" - "encoding/hex" "encoding/json" "fmt" "net/http" - "strings" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -34,23 +32,15 @@ func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase dctx, cancel := context.WithTimeout(detached, gc.commonTimeout) defer cancel() return firstClientResult(dctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { + // phase0.Root.UnmarshalJSON parses and length-checks the "0x…" hex, so decode straight into it. var resp struct { - DependentRoot string `json:"dependent_root"` + DependentRoot phase0.Root `json:"dependent_root"` } url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) if err := ptcDo(ctx, ptcHTTPClient, http.MethodGet, url, nil, nil, &resp); err != nil { return phase0.Root{}, err } - raw, err := hex.DecodeString(strings.TrimPrefix(resp.DependentRoot, "0x")) - if err != nil { - return phase0.Root{}, fmt.Errorf("decode dependent_root %q: %w", resp.DependentRoot, err) - } - var root phase0.Root - if len(raw) != len(root) { - return phase0.Root{}, fmt.Errorf("dependent_root: expected %d bytes, got %d", len(root), len(raw)) - } - copy(root[:], raw) - return root, nil + return resp.DependentRoot, nil }) }) return root, err From 507fde57eae846cc470da2d5b2aee8a5efd344ae Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 17:12:08 +0300 Subject: [PATCH 101/150] gloas: extract requestProposerDutiesDependentRoot + cover it with tests ProposerDutiesDependentRoot inlined its fetch+decode in the firstClientResult closure, unlike the sibling raw-HTTP endpoints (PTC duties/data, gloas block, envelope) that each delegate to a free request* helper unit-tested against an httptest server. Match that pattern: extract requestProposerDutiesDependentRoot and test it (happy path + malformed-root rejection), closing the gap that the dependent_root decode had no direct test. --- beacon/goclient/proposer_preferences.go | 23 ++++++++++------ beacon/goclient/proposer_preferences_test.go | 29 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 4fb3cdb1da..b7c2bc7b7d 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -32,20 +32,25 @@ func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase dctx, cancel := context.WithTimeout(detached, gc.commonTimeout) defer cancel() return firstClientResult(dctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { - // phase0.Root.UnmarshalJSON parses and length-checks the "0x…" hex, so decode straight into it. - var resp struct { - DependentRoot phase0.Root `json:"dependent_root"` - } - url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) - if err := ptcDo(ctx, ptcHTTPClient, http.MethodGet, url, nil, nil, &resp); err != nil { - return phase0.Root{}, err - } - return resp.DependentRoot, nil + return requestProposerDutiesDependentRoot(ctx, ptcHTTPClient, addr, epoch) }) }) return root, err } +// requestProposerDutiesDependentRoot GETs the v2 proposer-duties response and returns its dependent_root. +// phase0.Root.UnmarshalJSON parses and length-checks the "0x…" hex, so decode straight into it. +func requestProposerDutiesDependentRoot(ctx context.Context, httpClient *http.Client, addr string, epoch phase0.Epoch) (phase0.Root, error) { + var resp struct { + DependentRoot phase0.Root `json:"dependent_root"` + } + url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) + if err := ptcDo(ctx, httpClient, http.MethodGet, url, nil, nil, &resp); err != nil { + return phase0.Root{}, err + } + return resp.DependentRoot, nil +} + // SubmitProposerPreferences broadcasts signed Gloas (ePBS) proposer preferences (SIP #94 §5) to every // beacon client, succeeding if at least one accepts them; each BN verifies them and gossips on the // proposer_preferences topic. diff --git a/beacon/goclient/proposer_preferences_test.go b/beacon/goclient/proposer_preferences_test.go index 3a3c68b1ef..0501227e7f 100644 --- a/beacon/goclient/proposer_preferences_test.go +++ b/beacon/goclient/proposer_preferences_test.go @@ -49,3 +49,32 @@ func TestSubmitProposerPreferences(t *testing.T) { require.NoError(t, err) require.JSONEq(t, string(want), string(gotBody)) } + +func TestRequestProposerDutiesDependentRoot(t *testing.T) { + want := phase0.Root{0xde, 0xad, 0xbe, 0xef} + + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + // phase0.Root marshals to the "0x…" JSON string the endpoint returns. + _ = json.NewEncoder(w).Encode(map[string]any{"dependent_root": want, "data": []any{}}) + })) + defer srv.Close() + + got, err := requestProposerDutiesDependentRoot(context.Background(), srv.Client(), srv.URL, 3) + require.NoError(t, err) + require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, "/eth/v2/validator/duties/proposer/3", gotPath) + require.Equal(t, want, got) +} + +// A dependent_root that is not a valid 32-byte "0x…" root is rejected (phase0.Root.UnmarshalJSON). +func TestRequestProposerDutiesDependentRootRejectsMalformed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"dependent_root":"0x00","data":[]}`) + })) + defer srv.Close() + + _, err := requestProposerDutiesDependentRoot(context.Background(), srv.Client(), srv.URL, 3) + require.Error(t, err) +} From 57b9a37fbdec45d86625dbdccf42a5e3fed580f0 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 17:30:12 +0300 Subject: [PATCH 102/150] =?UTF-8?q?gloas:=20treat=20"already=20known"=20?= =?UTF-8?q?=C2=A74=20block=20submit=20as=20success=20(#2922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every operator submits the decided Gloas block for liveness redundancy, on the assumption the beacon node dedupes by root. Lodestar instead rejects the non-leader duplicate with 500 BLOCK_ERROR_ALREADY_KNOWN, which surfaced as a spurious "could not submit gloas beacon block" error. Treat an already-known response as success (the block is canonical), keeping both the all-submit redundancy and the per-node "≥1 submitted" contract the aetheria suite asserts. gloasOctetStreamHTTP now returns a typed gloasHTTPError exposing status + body so the submit path can classify the response. --- beacon/goclient/gloas_proposer.go | 35 ++++++++++++++++++++++++-- beacon/goclient/gloas_proposer_test.go | 32 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index e1fea7a661..ae707c1b47 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -66,12 +67,30 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, return block, nil } -// submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. +// submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. A response +// signalling the block is already known is treated as success: every operator submits the decided block +// for liveness redundancy, so a non-leader's submit legitimately races the canonical one, and some beacon +// nodes (e.g. Lodestar) report that duplicate as an error rather than deduping silently. func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ, nil) + if isBlockAlreadyKnown(err) { + return nil + } return err } +// isBlockAlreadyKnown reports whether err is a beacon-node response signalling the submitted block is +// already known (i.e. already canonical). Beacon-APIs has no standard code for this, so match on the +// message: Lodestar returns 500 "BLOCK_ERROR_ALREADY_KNOWN". +func isBlockAlreadyKnown(err error) bool { + var httpErr *gloasHTTPError + if !errors.As(err, &httpErr) { + return false + } + body := strings.ToLower(httpErr.body) + return strings.Contains(body, "already known") || strings.Contains(body, "already_known") +} + // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns // the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas // consensus version. extraHeaders (e.g. Eth-Execution-Payload-Blinded for the §6 envelope) are applied last. @@ -104,7 +123,19 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBody))) + return nil, &gloasHTTPError{method: method, url: url, statusCode: resp.StatusCode, body: strings.TrimSpace(string(respBody))} } return respBody, nil } + +// gloasHTTPError is the error gloasOctetStreamHTTP returns for a non-2xx response. It exposes the status +// code and body so callers can special-case specific beacon-node responses (see isBlockAlreadyKnown). +type gloasHTTPError struct { + method, url string + statusCode int + body string +} + +func (e *gloasHTTPError) Error() string { + return fmt.Sprintf("%s %s: status %d: %s", e.method, e.url, e.statusCode, e.body) +} diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 82d2e0c399..c126dccfc8 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -2,6 +2,7 @@ package goclient import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -90,3 +91,34 @@ func TestGloasOctetStreamHTTP_Non2xxIsError(t *testing.T) { _, err := gloasOctetStreamHTTP(context.Background(), http.MethodGet, srv.URL, nil, nil) require.ErrorContains(t, err, "status 400") } + +// A block the beacon node already knows (canonical) is treated as a successful submit: every operator +// submits the decided block for redundancy, so non-leader duplicates must not surface as errors. +func TestSubmitGloasBeaconBlock_AlreadyKnownIsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"code":500,"message":"BLOCK_ERROR_ALREADY_KNOWN"}`) // Lodestar's response + })) + defer srv.Close() + + require.NoError(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02})) +} + +// A genuine rejection (not "already known") still propagates as an error. +func TestSubmitGloasBeaconBlock_RealErrorPropagates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"code":400,"message":"invalid block"}`) + })) + defer srv.Close() + + require.Error(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02})) +} + +func TestIsBlockAlreadyKnown(t *testing.T) { + require.False(t, isBlockAlreadyKnown(nil)) + require.False(t, isBlockAlreadyKnown(errors.New("some other error"))) + require.False(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusBadRequest, body: "invalid block"})) + require.True(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"BLOCK_ERROR_ALREADY_KNOWN"}`})) + require.True(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusAccepted, body: "block already known"})) +} From 1efe42c2d946ab428e8a70c78e7ca34723217812 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 2 Jul 2026 17:59:45 +0300 Subject: [PATCH 103/150] =?UTF-8?q?gloas:=20publish=20the=20full=20=C2=A76?= =?UTF-8?q?=20envelope=20(unblinded)=20to=20match=20Lodestar=20(#2921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §6 envelope publish sent the blinded SignedBlindedExecutionPayloadEnvelope (~128 B), but Lodestar v1.43.0 — the first CL to implement the endpoint — decodes the publish body as a full SignedExecutionPayloadEnvelope and has no Eth-Execution-Payload-Blinded path, so it read the 128 B body's offsets as garbage (400 "Offset out of bounds ... > 128"). Publish the full signed envelope and drop the blinded header. The node already reconstructs that full envelope, and its hash-tree root equals the blinded root the §6 QBFT signed, so the signature stays valid. beacon-APIs#580 also defines blinded and stateless-Contents (envelope+blobs+KZG) publish bodies, but Lodestar v1.43.0 implements neither; the blinded types are kept for that deferred path. Verified against Lodestar v1.43.0 source: publishExecutionPayloadEnvelope decodes SignedExecutionPayloadEnvelope, and getExecutionPayloadEnvelope returns a bare envelope (no blobs). --- beacon/goclient/gloas_envelope.go | 34 ++++++++++++------------ beacon/goclient/gloas_envelope_test.go | 36 +++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index 1aec1cf8f6..89c5a45b3f 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -12,7 +12,7 @@ import ( ) // Gloas §6 envelope produce/publish endpoints (beacon-APIs#580, merged 2026-06-29). Produce takes the -// beacon block root as a path segment; publish posts the blinded body (see SubmitExecutionPayloadEnvelope). +// beacon block root as a path segment; publish posts the full signed envelope (see SubmitExecutionPayloadEnvelope). const ( gloasProduceEnvelopePath = "/eth/v1/validator/execution_payload_envelopes/%d/%s" // slot, beacon_block_root 0x-hex gloasPublishEnvelopePath = "/eth/v1/beacon/execution_payload_envelopes" @@ -26,18 +26,20 @@ func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0 }) } -// SubmitExecutionPayloadEnvelope publishes the signed §6 envelope as its blinded SSZ form to all -// configured beacon nodes concurrently, succeeding if at least one accepts it. The producing BN -// reconstructs the full payload from its cache. Re-publishing to multiple BNs is safe — they dedupe by -// block root. +// SubmitExecutionPayloadEnvelope publishes the full signed §6 envelope as SSZ to all configured beacon +// nodes concurrently, succeeding if at least one accepts it. Re-publishing to multiple BNs is safe — they +// dedupe by block root. +// +// The body is the full SignedExecutionPayloadEnvelope. beacon-APIs#580 also defines a blinded body +// (Eth-Execution-Payload-Blinded, reconstructed from the BN's cache) and an unblinded +// SignedExecutionPayloadEnvelopeContents (envelope + blobs + KZG proofs), but Lodestar v1.43.0 — the first +// CL to implement the endpoint — decodes only the full SignedExecutionPayloadEnvelope. The full envelope's +// hash-tree root equals the blinded root the §6 QBFT signed, so the reconstructed signature stays valid. +// The blinded form is retained in the gloas types for the deferred blinded/Contents path. func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { - blinded, err := signed.Blinded() + body, err := signed.MarshalSSZ() if err != nil { - return fmt.Errorf("blind execution payload envelope: %w", err) - } - body, err := blinded.MarshalSSZ() - if err != nil { - return fmt.Errorf("marshal signed blinded execution payload envelope: %w", err) + return fmt.Errorf("marshal signed execution payload envelope: %w", err) } ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) @@ -62,10 +64,10 @@ func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phas return envelope, nil } -// submitExecutionPayloadEnvelope POSTs the SSZ-marshaled signed blinded envelope to the publish endpoint, -// tagged Eth-Execution-Payload-Blinded. -func submitExecutionPayloadEnvelope(ctx context.Context, addr string, blindedEnvelopeSSZ []byte) error { - headers := map[string]string{"Eth-Execution-Payload-Blinded": "true"} - _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, blindedEnvelopeSSZ, headers) +// submitExecutionPayloadEnvelope POSTs the SSZ-marshaled full signed envelope to the publish endpoint. No +// Eth-Execution-Payload-Blinded header: Lodestar decodes the body as a full SignedExecutionPayloadEnvelope +// (gloasOctetStreamHTTP tags the request with the Gloas Eth-Consensus-Version). +func submitExecutionPayloadEnvelope(ctx context.Context, addr string, envelopeSSZ []byte) error { + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ, nil) return err } diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go index 9c99e8bc6e..0a692c3b1b 100644 --- a/beacon/goclient/gloas_envelope_test.go +++ b/beacon/goclient/gloas_envelope_test.go @@ -7,9 +7,11 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" + "go.uber.org/zap" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" @@ -65,7 +67,39 @@ func TestSubmitExecutionPayloadEnvelope(t *testing.T) { require.Equal(t, http.MethodPost, gotMethod) require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes", gotPath) require.Equal(t, consensusVersionGloas, gotVersion) - require.Equal(t, "true", gotBlinded) // published as the blinded (stateful) body + require.Empty(t, gotBlinded) // full envelope, not blinded — no Eth-Execution-Payload-Blinded header require.Equal(t, "application/octet-stream", gotContentType) require.Equal(t, []byte{0x01, 0x02}, gotBody) } + +// The publish sends the full SignedExecutionPayloadEnvelope SSZ (what Lodestar v1.43.0 decodes), not the +// blinded form the node signs over. +func TestSubmitExecutionPayloadEnvelope_PublishesFullSignedEnvelope(t *testing.T) { + signed := &gloas.SignedExecutionPayloadEnvelope{ + Message: minimalExecutionPayloadEnvelope(), + Signature: phase0.BLSSignature{0x01}, + } + wantBody, err := signed.MarshalSSZ() + require.NoError(t, err) + + var gotBlinded string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBlinded = r.Header.Get("Eth-Execution-Payload-Blinded") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &aggregatorClientMock{} + gc := &GoClient{ + log: zap.NewNop(), + clients: []Client{client}, + clientAddresses: map[Client]string{client: srv.URL}, + commonTimeout: time.Second, + } + + require.NoError(t, gc.SubmitExecutionPayloadEnvelope(t.Context(), signed)) + require.Empty(t, gotBlinded, "publish must not blind the envelope") + require.Equal(t, wantBody, gotBody, "publish must send the full signed envelope SSZ") +} From 2f10a8dccfc2069684aba4333f24e5c736f6dc16 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 3 Jul 2026 11:36:22 +0300 Subject: [PATCH 104/150] gloas: fix slot-fraction wording left stale by the interval retiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIP #94 §1 retiming made duty deadlines interval-based (intervals of 1/3 of the slot before Gloas, 1/4 from Gloas on), leaving a few comments and one error message still hard-coding pre-Gloas fractions: - aggregator_committee: the aggregation-deadline wait call site said "spec: 2/3 into slot" - goclient fetchVersionedAggregate: "gossip backfill before 2/3 of the slot" is now "before the aggregation deadline" - goclient GetSyncCommitteeContribution: the "wait for 1/3 of slot" error is now "wait for sync message deadline", matching the sibling aggregation/contribution deadline errors; test assertion updated - duties scheduler: SlotTicker doc said "1/3 of slot-time past slot start" though the ticker waits IntervalDuration(slot) Also corrects waitIntoSlot's deadline labels: intervals=1 is the attestation/sync-message deadline and intervals=2 the aggregation/contribution deadline (contributions are due at two intervals, as its own callers already say). --- beacon/goclient/aggregator.go | 11 ++++-- .../goclient/sync_committee_contribution.go | 2 +- beacon/goclient/sync_committee_test.go | 36 +++++++++++++++++++ operator/duties/scheduler.go | 2 +- .../v2/ssv/runner/aggregator_committee.go | 2 +- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index e7998415b0..2784c8e21f 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -83,9 +83,9 @@ func (gc *GoClient) SubmitSignedAggregateSelectionProof( } // waitIntoSlot waits until the given number of intervals into the slot has transpired -// (intervals * IntervalDuration after the start of the slot): intervals=1 is one interval in -// (attestation/contribution deadline), intervals=2 is two intervals in (aggregate broadcast -// deadline). IntervalDuration is 1/3 of the slot before Gloas, 1/4 from Gloas on (SIP #94 §1). +// (intervals * IntervalDuration after the start of the slot): intervals=1 is the attestation and +// sync-message deadline, intervals=2 the aggregation and contribution deadline. IntervalDuration +// is 1/3 of the slot before Gloas, 1/4 from Gloas on (SIP #94 §1). func (gc *GoClient) waitIntoSlot(ctx context.Context, slot phase0.Slot, intervals int) error { config := gc.getBeaconConfig() finalTime := config.SlotStartTime(slot).Add(time.Duration(intervals) * config.IntervalDuration(slot)) @@ -148,6 +148,11 @@ func (gc *GoClient) computeAttestationDataRoot( // cluster-decided value): the beacon node then holds at least our own attestation matching it. // Re-deriving the data locally can yield a root nobody attested with, which the node answers // with a 404 (no matching aggregate). +// +// A cache hit only guarantees that *some* beacon node accepted the attestation. The +// AggregateAttestation request below is routed to the single highest-scored client and does +// not fail over on 4xx, so it can still 404 if that node hasn't ingested the attestation yet +// (gossip backfill before the aggregation deadline makes this rare). func (gc *GoClient) fetchVersionedAggregate( ctx context.Context, slot phase0.Slot, diff --git a/beacon/goclient/sync_committee_contribution.go b/beacon/goclient/sync_committee_contribution.go index 40d75b91eb..4adeffa35a 100644 --- a/beacon/goclient/sync_committee_contribution.go +++ b/beacon/goclient/sync_committee_contribution.go @@ -52,7 +52,7 @@ func (gc *GoClient) GetSyncCommitteeContribution( } if err := gc.waitIntoSlot(ctx, slot, 1); err != nil { - return nil, DataVersionNil, fmt.Errorf("wait for 1/3 of slot: %w", err) + return nil, DataVersionNil, fmt.Errorf("wait for sync message deadline: %w", err) } // Resolve the contribution root from head rather than by slot: sync-committee messages diff --git a/beacon/goclient/sync_committee_test.go b/beacon/goclient/sync_committee_test.go index 9a3b61c7e8..377a74fd7f 100644 --- a/beacon/goclient/sync_committee_test.go +++ b/beacon/goclient/sync_committee_test.go @@ -356,6 +356,42 @@ func TestGetSyncCommitteeContributionFetchesHeadRoot(t *testing.T) { require.Equal(t, headRoot, (*contributions)[0].Contribution.BeaconBlockRoot) } +// TestGetSyncCommitteeContributionPropagatesCanceledContext locks in the cancellation +// path of the sync-message-deadline wait. With a future slot (so the wait blocks instead +// of early-returning) and an already-canceled context, GetSyncCommitteeContribution must +// return the wrapped wait error and DataVersionNil, without ever querying the beacon node. +func TestGetSyncCommitteeContributionPropagatesCanceledContext(t *testing.T) { + t.Parallel() + + cfg := *networkconfig.TestNetwork.Beacon + // A slot well into the future so waitIntoSlot blocks rather than early-returning. + slot := cfg.EstimatedCurrentSlot() + 1_000_000 + selectionProofs := []phase0.BLSSignature{signatureWithFirstByte(1)} + subnetIDs := []uint64{7} + + client := &syncCommitteeClientMock{ + beaconBlockRootFunc: func(_ context.Context, _ *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) { + t.Error("beacon node must not be queried when the context is canceled before the wait completes") + return nil, errors.New("unexpected call") + }, + } + + goClient := &GoClient{ + log: zap.NewNop(), + beaconConfig: &cfg, + multiClient: client, + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + got, version, err := goClient.GetSyncCommitteeContribution(ctx, slot, selectionProofs, subnetIDs) + require.ErrorContains(t, err, "wait for sync message deadline") + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, DataVersionNil, version) + require.Nil(t, got) +} + func TestIsSyncCommitteeAggregatorHandlesZeroModulo(t *testing.T) { t.Parallel() diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index cd230211f8..b5d7043ff9 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -347,7 +347,7 @@ func (f *EventFeed[T]) FanOut(ctx context.Context, in <-chan T) { } } -// SlotTicker advances "head" slot every slot-tick once we are 1/3 of slot-time past slot start +// SlotTicker advances "head" slot every slot-tick once we are one interval past slot start // and only if necessary. Normally Beacon node events would trigger "head" slot updates, but in // case event is delayed or didn't arrive for some reason we still need to advance "head" slot // for duties to keep executing normally - so SlotTicker is a secondary mechanism for that. diff --git a/protocol/v2/ssv/runner/aggregator_committee.go b/protocol/v2/ssv/runner/aggregator_committee.go index 6bfe092245..34e7676d6c 100644 --- a/protocol/v2/ssv/runner/aggregator_committee.go +++ b/protocol/v2/ssv/runner/aggregator_committee.go @@ -594,7 +594,7 @@ func (r *AggregatorCommitteeRunner) ProcessPreConsensus( } if len(aggregatorSelections) > 0 { - // Wait once per duty before fetching aggregate attestations (spec: 2/3 into slot). + // Wait once per duty until the spec's aggregation deadline before fetching aggregate attestations. if err := r.waitTwoIntervalsIntoSlot(ctx, duty.DutySlot()); err != nil { // Only reachable on shutdown (ctx canceled) within this short wait — markDutyFailed // would drop a context.Canceled reason anyway, so there is nothing to record here. From 40d2be6aa41517577161996c75a2e051ce2fb344 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 3 Jul 2026 12:02:52 +0300 Subject: [PATCH 105/150] gloas: fix "signalling" misspellings flagged by the misspell linter --- beacon/goclient/gloas_proposer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index ae707c1b47..a4c0be6013 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -68,7 +68,7 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, } // submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. A response -// signalling the block is already known is treated as success: every operator submits the decided block +// signaling the block is already known is treated as success: every operator submits the decided block // for liveness redundancy, so a non-leader's submit legitimately races the canonical one, and some beacon // nodes (e.g. Lodestar) report that duplicate as an error rather than deduping silently. func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { @@ -79,7 +79,7 @@ func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) e return err } -// isBlockAlreadyKnown reports whether err is a beacon-node response signalling the submitted block is +// isBlockAlreadyKnown reports whether err is a beacon-node response signaling the submitted block is // already known (i.e. already canonical). Beacon-APIs has no standard code for this, so match on the // message: Lodestar returns 500 "BLOCK_ERROR_ALREADY_KNOWN". func isBlockAlreadyKnown(err error) bool { From 188c6340bf7cf0bfdd134a202c1efd58eea87906 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 3 Jul 2026 12:10:18 +0300 Subject: [PATCH 106/150] gloas: drop embedded-field selector in gloasBlockStub (staticcheck QF1008) The main-module misspell failures were masking this: make lint runs the ssvsigner module's golangci pass only after the main module's passes, so this one surfaced only once the misspellings were fixed. Full make lint (both golangci passes, deadcode, openapi, ssvsigner-boundary) now passes locally. --- ssvsigner/ekm/local_key_manager_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 69d7ee336c..25e8de0c35 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -345,7 +345,7 @@ type gloasBlockStub struct { *phase0.BeaconBlockHeader } -func (b gloasBlockStub) BlockSlot() phase0.Slot { return b.BeaconBlockHeader.Slot } +func (b gloasBlockStub) BlockSlot() phase0.Slot { return b.Slot } // newLocalKeyManagerWithShare returns a LocalKeyManager with sk1 added as a share, plus its public key. func newLocalKeyManagerWithShare(t *testing.T) (*LocalKeyManager, phase0.BLSPubKey) { From fb1cbd958d97051ab638d55806327732917b4b6d Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 3 Jul 2026 14:09:31 +0300 Subject: [PATCH 107/150] =?UTF-8?q?gloas:=20treat=20"already=20known"=20?= =?UTF-8?q?=C2=A76=20envelope=20publish=20as=20success=20(#2923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §6 analog of #2922. On the self-build path every operator publishes the identical execution-payload envelope, so the non-winning ones race the canonical publish and Lodestar rejects them with 500 EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN — which surfaced at ERROR (could not submit execution payload envelope), marked the §6 duty failed on those operators, and skewed failed-submission metrics. Generalize the §4 already-known classifier (isBlockAlreadyKnown -> isAlreadyKnown; it already matched the envelope token) and apply it to the envelope publish, so an already-known envelope is treated as success — keeping the all-publish redundancy, matching the §4 handling. --- beacon/goclient/gloas_envelope.go | 7 +++++++ beacon/goclient/gloas_envelope_test.go | 13 +++++++++++++ beacon/goclient/gloas_proposer.go | 14 ++++++++------ beacon/goclient/gloas_proposer_test.go | 13 +++++++------ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index 89c5a45b3f..34f6c8d536 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -67,7 +67,14 @@ func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phas // submitExecutionPayloadEnvelope POSTs the SSZ-marshaled full signed envelope to the publish endpoint. No // Eth-Execution-Payload-Blinded header: Lodestar decodes the body as a full SignedExecutionPayloadEnvelope // (gloasOctetStreamHTTP tags the request with the Gloas Eth-Consensus-Version). +// +// An already-known response is treated as success: on the self-build path every operator publishes the +// identical envelope, so the non-winning ones race the canonical one and get EXECUTION_PAYLOAD_ENVELOPE_ +// ERROR_ALREADY_KNOWN — the §6 analog of the §4 block submit (see submitGloasBeaconBlock). func submitExecutionPayloadEnvelope(ctx context.Context, addr string, envelopeSSZ []byte) error { _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ, nil) + if isAlreadyKnown(err) { + return nil + } return err } diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go index 0a692c3b1b..20081c5bbb 100644 --- a/beacon/goclient/gloas_envelope_test.go +++ b/beacon/goclient/gloas_envelope_test.go @@ -103,3 +103,16 @@ func TestSubmitExecutionPayloadEnvelope_PublishesFullSignedEnvelope(t *testing.T require.Empty(t, gotBlinded, "publish must not blind the envelope") require.Equal(t, wantBody, gotBody, "publish must send the full signed envelope SSZ") } + +// An envelope the beacon node already knows is treated as a successful publish: on self-build every +// operator publishes the identical envelope, so the non-winning ones race the canonical one (§6 analog +// of the §4 block submit). +func TestSubmitExecutionPayloadEnvelope_AlreadyKnownIsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"code":500,"message":"EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN"}`) // Lodestar's response + })) + defer srv.Close() + + require.NoError(t, submitExecutionPayloadEnvelope(context.Background(), srv.URL, []byte{0x01, 0x02})) +} diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index a4c0be6013..0efb6921c3 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -73,16 +73,18 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, // nodes (e.g. Lodestar) report that duplicate as an error rather than deduping silently. func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ, nil) - if isBlockAlreadyKnown(err) { + if isAlreadyKnown(err) { return nil } return err } -// isBlockAlreadyKnown reports whether err is a beacon-node response signaling the submitted block is -// already known (i.e. already canonical). Beacon-APIs has no standard code for this, so match on the -// message: Lodestar returns 500 "BLOCK_ERROR_ALREADY_KNOWN". -func isBlockAlreadyKnown(err error) bool { +// isAlreadyKnown reports whether err is a beacon-node response signaling the submitted object is already +// known (i.e. already canonical) — for both the §4 block and the §6 envelope publish, where every operator +// redundantly submits the same object and the non-winning ones race the canonical one. Beacon-APIs has no +// standard code for this, so match on the message: Lodestar returns 500 "BLOCK_ERROR_ALREADY_KNOWN" and +// "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN". +func isAlreadyKnown(err error) bool { var httpErr *gloasHTTPError if !errors.As(err, &httpErr) { return false @@ -129,7 +131,7 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, } // gloasHTTPError is the error gloasOctetStreamHTTP returns for a non-2xx response. It exposes the status -// code and body so callers can special-case specific beacon-node responses (see isBlockAlreadyKnown). +// code and body so callers can special-case specific beacon-node responses (see isAlreadyKnown). type gloasHTTPError struct { method, url string statusCode int diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index c126dccfc8..4f41c96641 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -115,10 +115,11 @@ func TestSubmitGloasBeaconBlock_RealErrorPropagates(t *testing.T) { require.Error(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02})) } -func TestIsBlockAlreadyKnown(t *testing.T) { - require.False(t, isBlockAlreadyKnown(nil)) - require.False(t, isBlockAlreadyKnown(errors.New("some other error"))) - require.False(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusBadRequest, body: "invalid block"})) - require.True(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"BLOCK_ERROR_ALREADY_KNOWN"}`})) - require.True(t, isBlockAlreadyKnown(&gloasHTTPError{statusCode: http.StatusAccepted, body: "block already known"})) +func TestIsAlreadyKnown(t *testing.T) { + require.False(t, isAlreadyKnown(nil)) + require.False(t, isAlreadyKnown(errors.New("some other error"))) + require.False(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusBadRequest, body: "invalid block"})) + require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"BLOCK_ERROR_ALREADY_KNOWN"}`})) + require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN"}`})) + require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusAccepted, body: "block already known"})) } From b42af094f018438c521975340192ae2c9ad2c117 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 8 Jul 2026 11:19:26 +0300 Subject: [PATCH 108/150] =?UTF-8?q?gloas:=20enable=20=C2=A75=20proposer-pr?= =?UTF-8?q?eferences=20re-emission=20on=20dependent=5Froot=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIP #94 §5 has a proposer re-emit its preference when the proposal slot's dependent_root changes (e.g. after a reorg). Two node-side gaps blocked it: - Message validation capped ProposerPreferences at one pre-consensus partial sig per (slot, signer), so a refresh (a new signing root) was rejected as a duplicate and gossip-penalized. Now track distinct signing roots per (slot, signer) and admit up to 4, treating a repeat root as a duplicate (same-peer REJECT, relayed IGNORE). - The proposer-preferences scheduler re-emitted on every reorg, so a no-op reorg re-broadcast an identical preference and self-inflicted a gossip penalty. Now track the dependent_root last emitted per epoch and, on a reorg, re-emit only the epochs whose dependent_root actually changed (ProposerDutiesDependentRoot added to the scheduler BeaconNode interface). Cleanups: fix a stale filename doc comment, and make ProposerPreferences explicitly root-tracked rather than reusing the shared pre-consensus bit. --- message/validation/const.go | 6 ++ message/validation/partial_validation.go | 34 ++++++- .../validation/proposer_preferences_test.go | 96 ++++++++++++++++++- message/validation/seen_msg_types.go | 7 +- message/validation/signer_state.go | 27 ++++++ operator/duties/beacon_adapter.go | 5 + operator/duties/proposer_preferences.go | 78 ++++++++------- operator/duties/proposer_preferences_test.go | 75 +++++++++++---- operator/duties/scheduler.go | 1 + operator/duties/scheduler_mock.go | 15 +++ .../v2/ssv/runner/proposer_preferences.go | 7 +- 11 files changed, 291 insertions(+), 60 deletions(-) diff --git a/message/validation/const.go b/message/validation/const.go index ba9d4a38ff..e81f26f9ea 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -29,6 +29,12 @@ const ( // bounds both how early such a message may arrive and how many slots of per-signer state to retain. const proposerPreferencesEarlyEpochs = 2 +// maxProposerPreferencesDistinctRoots bounds the distinct ProposerPreferences signing roots one +// (slot, signer) may contribute (SIP #94 §5). Unlike other pre-consensus messages (capped at 1), a +// proposer re-emits its preference under a new root when the proposal slot's dependent_root changes, so +// the bound admits a few genuine reorg-driven refreshes while still capping duplicates and flooding. +const maxProposerPreferencesDistinctRoots = 4 + const ( signatureSize = 256 signatureOffset = 0 diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index e15074daef..2dce3337d7 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -284,8 +284,7 @@ func validatePartialSignatureMessageLimit( switch m.Type { case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, - spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig, - spectypes.ProposerPreferencesPartialSig: + spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPreConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. e := ErrTooManyPartialSigMessage @@ -300,6 +299,28 @@ func validatePartialSignatureMessageLimit( e.got = fmt.Sprintf("pre-consensus, having %v", signerState.World.SeenMsgTypes.String()) return e } + case spectypes.ProposerPreferencesPartialSig: + // SIP #94 §5: admit up to maxProposerPreferencesDistinctRoots distinct signing roots per + // (slot, signer) — a dependent_root refresh re-emits under a new root — instead of the usual ≤1 + // pre-consensus cap; a repeat of an already-seen root is a logical duplicate. + root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) + peerState := signerState.Peer(receivedFrom) + if peerState.hasProposerPreferencesRoot(root) || + peerState.proposerPreferencesRootCount() >= maxProposerPreferencesDistinctRoots { + // Same peer re-sent a seen root, or exceeded its distinct-root budget — reject to punish. + e := ErrTooManyPartialSigMessage + e.reject = true + e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) from peer", peerState.proposerPreferencesRootCount()) + return e + } + if signerState.World.hasProposerPreferencesRoot(root) || + signerState.World.proposerPreferencesRootCount() >= maxProposerPreferencesDistinctRoots { + // A different peer already supplied this root, or the cluster-wide distinct-root budget is + // spent — ignore, as this is expected occasionally under gossip. + e := ErrTooManyPartialSigMessage + e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) world-wide", signerState.World.proposerPreferencesRootCount()) + return e + } case spectypes.PostConsensusPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPostConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. @@ -349,6 +370,15 @@ func (mv *messageValidator) updatePartialSignatureState( return err } + // SIP #94 §5: record the distinct signing root so a dependent_root re-emission is admitted up to the + // bound (see validatePartialSignatureMessageLimit). Exactly one signature for this role (validated + // earlier), so Messages[0] holds the root. + if partialSignatureMessages.Type == spectypes.ProposerPreferencesPartialSig { + root := partialSignatureMessages.Messages[0].SigningRoot + signerState.Peer(receivedFrom).recordProposerPreferencesRoot(root) + signerState.World.recordProposerPreferencesRoot(root) + } + return nil } diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index dcfb9e7534..c9545fc2a7 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -1,14 +1,18 @@ package validation import ( + "errors" "testing" "time" eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" - spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" + specqbft "github.com/ssvlabs/ssv-spec/qbft" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/operator/duties/dutystore" ) @@ -158,3 +162,93 @@ func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) unfetched := phase0.Slot(uint64(epoch+10) * netCfg.SlotsPerEpoch) require.NoError(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, unfetched, indices, false)) } + +// SignerState tracks distinct ProposerPreferences signing roots (SIP #94 §5): recording is idempotent +// per root, and has/count reflect the distinct set. +func TestSignerState_ProposerPreferencesRoots(t *testing.T) { + s := &SignerState{} + r1 := [32]byte{1} + r2 := [32]byte{2} + + require.Equal(t, 0, s.proposerPreferencesRootCount()) + require.False(t, s.hasProposerPreferencesRoot(r1)) + + s.recordProposerPreferencesRoot(r1) + require.True(t, s.hasProposerPreferencesRoot(r1)) + require.Equal(t, 1, s.proposerPreferencesRootCount()) + + // Recording an already-seen root is a no-op. + s.recordProposerPreferencesRoot(r1) + require.Equal(t, 1, s.proposerPreferencesRootCount()) + + s.recordProposerPreferencesRoot(r2) + require.True(t, s.hasProposerPreferencesRoot(r2)) + require.Equal(t, 2, s.proposerPreferencesRootCount()) +} + +// ProposerPreferences pre-consensus admits up to maxProposerPreferencesDistinctRoots distinct signing +// roots per (slot, signer) — a dependent_root refresh re-emits under a new root (SIP #94 §5) — while a +// repeat of a seen root is a logical duplicate (same-peer REJECT, relayed IGNORE). +func TestValidatePartialSignatureMessageLimit_ProposerPreferences(t *testing.T) { + ppMsg := func(root [32]byte) *spectypes.PartialSignatureMessages { + return &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: 1, + Messages: []*spectypes.PartialSignatureMessage{{SigningRoot: root}}, + } + } + record := func(ss *SignerStateForSlotRound, from peer.ID, root [32]byte) { + ss.Peer(from).recordProposerPreferencesRoot(root) + ss.World.recordProposerPreferencesRoot(root) + } + root := func(b byte) [32]byte { return [32]byte{b} } + + const peerA = peer.ID("A") + const peerB = peer.ID("B") + + t.Run("distinct roots accepted up to the bound, then the peer's next distinct root is rejected", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + for i := 0; i < maxProposerPreferencesDistinctRoots; i++ { + r := root(byte(i + 1)) + require.NoError(t, validatePartialSignatureMessageLimit(ppMsg(r), peerA, ss)) + record(ss, peerA, r) + } + + var valErr Error + err := validatePartialSignatureMessageLimit(ppMsg(root(99)), peerA, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.True(t, valErr.reject) + }) + + t.Run("same-peer duplicate root is rejected, a relayed duplicate is ignored", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + r := root(1) + require.NoError(t, validatePartialSignatureMessageLimit(ppMsg(r), peerA, ss)) + record(ss, peerA, r) + + var valErr Error + err := validatePartialSignatureMessageLimit(ppMsg(r), peerA, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.True(t, valErr.reject) + + err = validatePartialSignatureMessageLimit(ppMsg(r), peerB, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.False(t, valErr.reject) + }) + + t.Run("a fresh peer's new distinct root is ignored once the world budget is spent", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + for i := 0; i < maxProposerPreferencesDistinctRoots; i++ { + record(ss, peerA, root(byte(i+1))) + } + + var valErr Error + err := validatePartialSignatureMessageLimit(ppMsg(root(99)), peerB, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.False(t, valErr.reject) + }) +} diff --git a/message/validation/seen_msg_types.go b/message/validation/seen_msg_types.go index c8a591b2ee..be5a69a9b6 100644 --- a/message/validation/seen_msg_types.go +++ b/message/validation/seen_msg_types.go @@ -1,6 +1,6 @@ package validation -// seen_msg_types.go contains code for counting and validating messages per validator-slot-round. +// seen_msg_types.go tracks which message types have been seen per validator-slot-round. import ( "fmt" @@ -80,8 +80,11 @@ func (c *SeenMsgTypes) RecordConsensusMessage(signedSSVMessage *spectypes.Signed // RecordPartialSignatureMessage updates the counts based on the provided partial signature message type. func (c *SeenMsgTypes) RecordPartialSignatureMessage(messages *spectypes.PartialSignatureMessages) error { switch messages.Type { - case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig, spectypes.ProposerPreferencesPartialSig: + case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: c.recordPreConsensus() + case spectypes.ProposerPreferencesPartialSig: + // Capped by distinct signing root rather than the single pre-consensus bit (SIP #94 §5); the root + // set is tracked on SignerState, so there is nothing to record in this type bitmask. case spectypes.PostConsensusPartialSig: c.recordPostConsensus() default: diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 1f7dd81a27..1c1012c579 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -3,8 +3,11 @@ package validation // signer_state.go describes state of a signer. import ( + "slices" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/libp2p/go-libp2p/core/peer" + specqbft "github.com/ssvlabs/ssv-spec/qbft" ) @@ -49,6 +52,7 @@ func (s *SignerStateForSlotRound) Reset(slot phase0.Slot, round specqbft.Round) s.World.SeenMsgTypes = SeenMsgTypes{} s.World.HashedProposalData = nil s.World.SeenDecidedMsgSignersCount = 0 + s.World.SeenProposerPreferencesRoots = nil } // SignerState represents the state of a signer (an Operator running a Runner that performs partial-signing for @@ -64,4 +68,27 @@ type SignerState struct { // SeenDecidedMsgSignersCount records the max number of signers we've seen with a decided message. SeenDecidedMsgSignersCount int + + // SeenProposerPreferencesRoots records the distinct ProposerPreferences signing roots seen from this + // signer (SIP #94 §5): that type is capped by distinct root (up to maxProposerPreferencesDistinctRoots), + // not by the single pre-consensus bit in SeenMsgTypes. nil until the first such message. + SeenProposerPreferencesRoots [][32]byte +} + +// hasProposerPreferencesRoot reports whether root has already been seen from this signer. +func (s *SignerState) hasProposerPreferencesRoot(root [32]byte) bool { + return slices.Contains(s.SeenProposerPreferencesRoots, root) +} + +// proposerPreferencesRootCount returns the number of distinct roots seen from this signer. +func (s *SignerState) proposerPreferencesRootCount() int { + return len(s.SeenProposerPreferencesRoots) +} + +// recordProposerPreferencesRoot adds root to the seen set, skipping roots already present. +func (s *SignerState) recordProposerPreferencesRoot(root [32]byte) { + if slices.Contains(s.SeenProposerPreferencesRoots, root) { + return + } + s.SeenProposerPreferencesRoots = append(s.SeenProposerPreferencesRoots, root) } diff --git a/operator/duties/beacon_adapter.go b/operator/duties/beacon_adapter.go index fa454988a9..49ae429188 100644 --- a/operator/duties/beacon_adapter.go +++ b/operator/duties/beacon_adapter.go @@ -290,6 +290,11 @@ func (p *prefetchingBeacon) ProposerDuties(ctx context.Context, epoch phase0.Epo return out, nil } +func (p *prefetchingBeacon) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + // Pass-through: a single cheap root, no prefetch/cache needed. + return p.inner.ProposerDutiesDependentRoot(ctx, epoch) +} + func (p *prefetchingBeacon) SyncCommitteeDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*eth2apiv1.SyncCommitteeDuty, error) { if err := p.ensureSyncPeriod(ctx, epoch, indices); err != nil { return nil, err diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index ea92a8091e..8af4eec139 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -18,14 +18,19 @@ import ( type ProposerPreferencesHandler struct { baseHandler - // processed records epochs already fetched and handled (preferences emitted, or confirmed to hold - // no local proposals), so each epoch fires once. Accessed only from the HandleDuties goroutine. - processed map[phase0.Epoch]struct{} + // emitted records the dependent_root last emitted for each epoch (SIP #94 §5). An epoch emits once + // per dependent_root: a steady-state tick skips an already-emitted epoch, while a post-reorg recheck + // re-emits only if the root actually changed. Accessed only from the HandleDuties goroutine. + emitted map[phase0.Epoch]phase0.Root + + // recheckLookahead is set by a reorg so the next tick re-evaluates each lookahead epoch's + // dependent_root instead of trusting its emitted marker. Accessed only from the HandleDuties goroutine. + recheckLookahead bool } func NewProposerPreferencesHandler() *ProposerPreferencesHandler { return &ProposerPreferencesHandler{ - processed: map[phase0.Epoch]struct{}{}, + emitted: map[phase0.Epoch]phase0.Root{}, } } @@ -55,52 +60,49 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { h.emitForTick(ctx, slot) case <-h.indicesChangeCh: - h.reEmitLookahead("indices change") + // New local validators may hold proposal slots in an already-emitted epoch, so drop the + // markers to re-emit the full lookahead for them on the next tick. + h.logger.Debug("🔀 re-emitting proposer preferences on indices change") + clear(h.emitted) case <-h.reorgEventsCh: - h.reEmitLookahead("reorg") + // A reorg may have changed a proposal slot's dependent_root; recheck the lookahead on the next + // tick and re-emit only the epochs whose root actually changed (SIP #94 §5). + h.logger.Debug("🔀 rechecking proposer-preferences dependent roots on reorg") + h.recheckLookahead = true } } } -// reEmitLookahead drops the emitted-epoch markers so the next tick re-fetches and re-emits the -// lookahead's preferences — after a reorg (new dependent_root) or a validator-set change (new local -// validators that missed an already-processed epoch). New local validators land on distinct proposal -// slots and emit correctly. -// -// KNOWN ISSUE (pending the SIP-94 §5 coordination rule): re-emitting for an already-emitted -// (proposal-slot, signer) — e.g. a changed dependent_root after a reorg — is rejected by the -// ≤1-per-(slot,signer) pre-consensus dedup, so the refresh neither converges nor replaces the prior -// preference, and the re-emitting operator is gossip-penalized. The fix (a bounded distinct-root -// allowance for ProposerPreferences pre-consensus, plus re-emitting only on a real dependent_root -// change) waits on the agreed SIP-94 §5 rule, since it relaxes a cross-client validation invariant. -func (h *ProposerPreferencesHandler) reEmitLookahead(reason string) { - h.logger.Debug("🔀 re-emitting proposer preferences on next tick", zap.String("reason", reason)) - clear(h.processed) -} - // emitForTick emits the lookahead's preferences for the tick's slot: the current epoch (plus the next, // once it's a good time to fetch) in steady state, or the first Gloas epoch when in the pre-fork -// window. Outside both it does nothing (pre-Gloas, no preferences yet). +// window. Outside both it does nothing (pre-Gloas, no preferences yet). A reorg recheck flagged since +// the last tick is consumed here, forcing the lookahead's dependent roots to be re-evaluated. func (h *ProposerPreferencesHandler) emitForTick(ctx context.Context, slot phase0.Slot) { + recheck := h.recheckLookahead + h.recheckLookahead = false + epoch := h.netCfg.EstimatedEpochAtSlot(slot) switch { case h.netCfg.IsGloas(epoch): - h.emitForEpoch(ctx, epoch, slot) + h.emitForEpoch(ctx, epoch, slot, recheck) if h.shouldFetchNextEpoch(slot) { - h.emitForEpoch(ctx, epoch+1, slot) + h.emitForEpoch(ctx, epoch+1, slot, recheck) } h.evictOutdated(epoch) case h.netCfg.InGloasPriorWindow(slot): // epoch+1 is GLOAS_FORK_EPOCH throughout the prior window (MIN_SEED_LOOKAHEAD=1). - h.emitForEpoch(ctx, epoch+1, slot) + h.emitForEpoch(ctx, epoch+1, slot, recheck) } } -// emitForEpoch fetches the epoch's proposer assignments for local validators once and emits one -// proposer-preferences duty per assignment, to be executed (broadcast) immediately. -func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch phase0.Epoch, currentSlot phase0.Slot) { - if _, done := h.processed[epoch]; done { +// emitForEpoch emits one proposer-preferences duty per local proposal assignment in the epoch, to be +// broadcast immediately. It emits once per (epoch, dependent_root): a steady-state tick skips an +// already-emitted epoch, and a post-reorg recheck re-emits only when the epoch's dependent_root changed — +// re-emitting under an unchanged root would just duplicate the preference and get the operator +// gossip-penalized (SIP #94 §5). +func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch phase0.Epoch, currentSlot phase0.Slot, recheck bool) { + if _, done := h.emitted[epoch]; done && !recheck { return } @@ -109,6 +111,15 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha return // no local validators yet; retry on the next tick } + dependentRoot, err := h.beaconNode.ProposerDutiesDependentRoot(ctx, epoch) + if err != nil { + h.logger.Warn("failed to fetch proposer-duties dependent root", fields.Epoch(epoch), zap.Error(err)) + return // retry on the next tick + } + if prev, done := h.emitted[epoch]; done && prev == dependentRoot { + return // dependent_root unchanged; a re-emission would only duplicate the preference + } + duties, err := h.beaconNode.ProposerDuties(ctx, epoch, indices) if err != nil { h.logger.Warn("failed to fetch proposer duties", fields.Epoch(epoch), zap.Error(err)) @@ -124,7 +135,7 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha ValidatorIndex: d.ValidatorIndex, }) } - h.processed[epoch] = struct{}{} + h.emitted[epoch] = dependentRoot if len(preferenceDuties) == 0 { return @@ -138,10 +149,11 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha h.logger.Debug("emitted proposer preferences duties", fields.Epoch(epoch), fields.Count(len(preferenceDuties)), + zap.String("dependent_root", dependentRoot.String()), ) } -// evictOutdated drops processed-epoch markers for epochs before the current one. +// evictOutdated drops emitted-epoch markers for epochs before the current one. func (h *ProposerPreferencesHandler) evictOutdated(currentEpoch phase0.Epoch) { - evictEpochsBefore(h.processed, currentEpoch) + evictEpochsBefore(h.emitted, currentEpoch) } diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index 760d613178..8fc204a4dc 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -32,6 +32,7 @@ func TestProposerPreferencesHandler_emitForEpoch_emitsAndCachesPerEpoch(t *testi Times(1) bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(phase0.Root{0xaa}, nil).Times(1) bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). Return([]*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}}, nil). Times(1) @@ -44,10 +45,10 @@ func TestProposerPreferencesHandler_emitForEpoch_emitsAndCachesPerEpoch(t *testi h.beaconNode = bn h.dutiesExecutor = &captureExecutor{executed: executed} - h.emitForEpoch(context.Background(), epoch, currentSlot) - h.emitForEpoch(context.Background(), epoch, currentSlot) // cached: must not re-fetch or re-emit + h.emitForEpoch(context.Background(), epoch, currentSlot, false) + h.emitForEpoch(context.Background(), epoch, currentSlot, false) // cached: must not re-fetch or re-emit - require.Contains(t, h.processed, epoch) + require.Contains(t, h.emitted, epoch) require.Len(t, executed, 1) got := <-executed @@ -71,9 +72,9 @@ func TestProposerPreferencesHandler_emitForEpoch_noLocalValidators(t *testing.T) h.logger = zap.NewNop() h.validatorProvider = vp - h.emitForEpoch(context.Background(), epoch, phase0.Slot(40)) + h.emitForEpoch(context.Background(), epoch, phase0.Slot(40), false) - require.NotContains(t, h.processed, epoch) + require.NotContains(t, h.emitted, epoch) } // When the beacon node reports no local proposals for the epoch, it's marked processed (no retry) and @@ -88,6 +89,7 @@ func TestProposerPreferencesHandler_emitForEpoch_noProposals(t *testing.T) { Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx}}}).Times(1) bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(phase0.Root{0xaa}, nil).Times(1) bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). Return([]*eth2apiv1.ProposerDuty{}, nil).Times(1) @@ -98,9 +100,9 @@ func TestProposerPreferencesHandler_emitForEpoch_noProposals(t *testing.T) { h.beaconNode = bn h.dutiesExecutor = &captureExecutor{executed: executed} - h.emitForEpoch(context.Background(), epoch, phase0.Slot(40)) + h.emitForEpoch(context.Background(), epoch, phase0.Slot(40), false) - require.Contains(t, h.processed, epoch) + require.Contains(t, h.emitted, epoch) require.Len(t, executed, 0) } @@ -108,14 +110,14 @@ func TestProposerPreferencesHandler_emitForEpoch_noProposals(t *testing.T) { func TestProposerPreferencesHandler_evictOutdated(t *testing.T) { h := NewProposerPreferencesHandler() for _, e := range []phase0.Epoch{4, 5, 6} { - h.processed[e] = struct{}{} + h.emitted[e] = phase0.Root{} } h.evictOutdated(5) - require.NotContains(t, h.processed, phase0.Epoch(4)) - require.Contains(t, h.processed, phase0.Epoch(5)) - require.Contains(t, h.processed, phase0.Epoch(6)) + require.NotContains(t, h.emitted, phase0.Epoch(4)) + require.Contains(t, h.emitted, phase0.Epoch(5)) + require.Contains(t, h.emitted, phase0.Epoch(6)) } // emitForTick emits the first Gloas epoch's preferences both in steady state (a slot in that epoch) @@ -144,6 +146,7 @@ func TestProposerPreferencesHandler_emitForTick(t *testing.T) { Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}).Times(1) bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), phase0.Epoch(gloasEpoch)).Return(phase0.Root{0xaa}, nil).Times(1) bn.EXPECT().ProposerDuties(gomock.Any(), phase0.Epoch(gloasEpoch), []phase0.ValidatorIndex{idx}). Return([]*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}}, nil).Times(1) @@ -166,16 +169,50 @@ func TestProposerPreferencesHandler_emitForTick(t *testing.T) { } } -// A reorg or indices change drops the emitted-epoch markers so the next tick re-fetches and re-emits -// the lookahead. -func TestProposerPreferencesHandler_reEmitLookahead_clearsProcessed(t *testing.T) { +// A post-reorg recheck re-emits an epoch's preferences only when its dependent_root actually changed: +// an unchanged root is skipped (re-emitting it would just duplicate the preference, SIP #94 §5), while +// a changed root re-emits. +func TestProposerPreferencesHandler_recheckReEmitsOnlyOnDependentRootChange(t *testing.T) { + ctrl := gomock.NewController(t) + + epoch := phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + proposalSlot := phase0.Slot(60) + currentSlot := phase0.Slot(40) + pk := phase0.BLSPubKey{1, 2, 3} + rootA := phase0.Root{0xaa} + rootB := phase0.Root{0xbb} + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}). + AnyTimes() + + duty := []*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}} + bn := NewMockBeaconNode(ctrl) + // The first emit and the changed-root recheck each fetch duties; the unchanged-root recheck must not. + gomock.InOrder( + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(rootA, nil), // first emit + bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}).Return(duty, nil), + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(rootA, nil), // recheck, unchanged → skip + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(rootB, nil), // recheck, changed → re-emit + bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}).Return(duty, nil), + ) + + executed := make(chan []*spectypes.ValidatorDuty, 2) h := NewProposerPreferencesHandler() h.logger = zap.NewNop() - for _, e := range []phase0.Epoch{100, 101} { - h.processed[e] = struct{}{} - } + h.netCfg = networkconfig.TestNetwork + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed} + + h.emitForEpoch(context.Background(), epoch, currentSlot, false) // first emit → root A + require.Equal(t, rootA, h.emitted[epoch]) - h.reEmitLookahead("test") + h.emitForEpoch(context.Background(), epoch, currentSlot, true) // recheck, unchanged → no re-emit + h.emitForEpoch(context.Background(), epoch, currentSlot, true) // recheck, changed → re-emit + require.Equal(t, rootB, h.emitted[epoch]) - require.Empty(t, h.processed) + require.Len(t, executed, 2) // first emit + changed-root re-emit only } diff --git a/operator/duties/scheduler.go b/operator/duties/scheduler.go index b5d7043ff9..f0e8b4bff0 100644 --- a/operator/duties/scheduler.go +++ b/operator/duties/scheduler.go @@ -55,6 +55,7 @@ type DutyExecutor interface { type BeaconNode interface { AttesterDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*eth2apiv1.AttesterDuty, error) ProposerDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*eth2apiv1.ProposerDuty, error) + ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) SyncCommitteeDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*eth2apiv1.SyncCommitteeDuty, error) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscription []*eth2apiv1.BeaconCommitteeSubscription) error diff --git a/operator/duties/scheduler_mock.go b/operator/duties/scheduler_mock.go index e086a3fbc9..307f8952a1 100644 --- a/operator/duties/scheduler_mock.go +++ b/operator/duties/scheduler_mock.go @@ -190,6 +190,21 @@ func (mr *MockBeaconNodeMockRecorder) ProposerDuties(ctx, epoch, validatorIndice return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDuties", reflect.TypeOf((*MockBeaconNode)(nil).ProposerDuties), ctx, epoch, validatorIndices) } +// ProposerDutiesDependentRoot mocks base method. +func (m *MockBeaconNode) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProposerDutiesDependentRoot", ctx, epoch) + ret0, _ := ret[0].(phase0.Root) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ProposerDutiesDependentRoot indicates an expected call of ProposerDutiesDependentRoot. +func (mr *MockBeaconNodeMockRecorder) ProposerDutiesDependentRoot(ctx, epoch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockBeaconNode)(nil).ProposerDutiesDependentRoot), ctx, epoch) +} + // SubmitBeaconCommitteeSubscriptions mocks base method. func (m *MockBeaconNode) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscription []*v1.BeaconCommitteeSubscription) error { m.ctrl.T.Helper() diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 936ef99c9f..42b5ce55c6 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -387,9 +387,10 @@ func (r *proposerPreferencesSlotRunner) buildProposerPreferences(ctx context.Con // KNOWN ISSUE (SIP-94 §5 publish-finality — pending): dependent_root/fee_recipient/target_gas_limit are // read here at emit time and the preference is published once pre-consensus quorum is reached, with no - // guard holding publication until they are final. A reorg can change dependent_root afterwards — and the - // ≤1-per-(slot,signer) pre-consensus dedup means the refresh can't be re-emitted (see the reEmitLookahead - // KNOWN ISSUE). Low severity (reorg-gated, §5 is observational); add a finality hold only if it bites on devnet. + // guard holding publication until they are final. A reorg that changes dependent_root is handled — the + // scheduler re-emits only on a real change and message validation admits the new signing root — but a + // preference already published under a soon-to-change root is not retracted. Low severity (reorg-gated, + // §5 is observational); add a finality hold only if it bites on devnet. return &gloas.ProposerPreferences{ DependentRoot: dependentRoot, ProposalSlot: proposalSlot, From c56c0136311629fb2a0c06929c67117b2af8c83d Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 8 Jul 2026 13:16:20 +0300 Subject: [PATCH 109/150] =?UTF-8?q?gloas:=20=C2=A75=20proposer-preferences?= =?UTF-8?q?=20dedup=20=E2=80=94=20IGNORE=20distinct=20root=20past=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIP #94 §7 classifies a distinct, validly-signed ProposerPreferences root past the per-(slot,signer) cap as rate-limiting (IGNORE), not a provable violation. REJECT now fires only on a same-peer repeat of an already-seen root; a relayed repeat or a distinct root past the world cap is IGNORE'd. --- message/validation/partial_validation.go | 13 ++++++------- message/validation/proposer_preferences_test.go | 9 +++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 2dce3337d7..f52555fb97 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -302,21 +302,20 @@ func validatePartialSignatureMessageLimit( case spectypes.ProposerPreferencesPartialSig: // SIP #94 §5: admit up to maxProposerPreferencesDistinctRoots distinct signing roots per // (slot, signer) — a dependent_root refresh re-emits under a new root — instead of the usual ≤1 - // pre-consensus cap; a repeat of an already-seen root is a logical duplicate. + // pre-consensus cap. Only a same-peer repeat of a seen root is a provable duplicate (REJECT); a + // relayed repeat or a distinct root beyond the cap is rate-limiting, not a provable violation (IGNORE). root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) - peerState := signerState.Peer(receivedFrom) - if peerState.hasProposerPreferencesRoot(root) || - peerState.proposerPreferencesRootCount() >= maxProposerPreferencesDistinctRoots { - // Same peer re-sent a seen root, or exceeded its distinct-root budget — reject to punish. + if signerState.Peer(receivedFrom).hasProposerPreferencesRoot(root) { + // Same peer re-sent a root it already sent — a logical duplicate; reject to punish. e := ErrTooManyPartialSigMessage e.reject = true - e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) from peer", peerState.proposerPreferencesRootCount()) + e.got = "proposer-preferences, duplicate signing root from peer" return e } if signerState.World.hasProposerPreferencesRoot(root) || signerState.World.proposerPreferencesRootCount() >= maxProposerPreferencesDistinctRoots { // A different peer already supplied this root, or the cluster-wide distinct-root budget is - // spent — ignore, as this is expected occasionally under gossip. + // spent — ignore either way; both are expected under gossip and neither is a provable violation. e := ErrTooManyPartialSigMessage e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) world-wide", signerState.World.proposerPreferencesRootCount()) return e diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index c9545fc2a7..cdb79d4d67 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -187,8 +187,8 @@ func TestSignerState_ProposerPreferencesRoots(t *testing.T) { } // ProposerPreferences pre-consensus admits up to maxProposerPreferencesDistinctRoots distinct signing -// roots per (slot, signer) — a dependent_root refresh re-emits under a new root (SIP #94 §5) — while a -// repeat of a seen root is a logical duplicate (same-peer REJECT, relayed IGNORE). +// roots per (slot, signer) — a dependent_root refresh re-emits under a new root (SIP #94 §5). Only a +// same-peer repeat of a seen root is REJECT'd; a relayed repeat or a distinct root past the cap is IGNORE'd. func TestValidatePartialSignatureMessageLimit_ProposerPreferences(t *testing.T) { ppMsg := func(root [32]byte) *spectypes.PartialSignatureMessages { return &spectypes.PartialSignatureMessages{ @@ -206,7 +206,7 @@ func TestValidatePartialSignatureMessageLimit_ProposerPreferences(t *testing.T) const peerA = peer.ID("A") const peerB = peer.ID("B") - t.Run("distinct roots accepted up to the bound, then the peer's next distinct root is rejected", func(t *testing.T) { + t.Run("distinct roots accepted up to the bound, then further distinct roots are ignored", func(t *testing.T) { ss := newSignerState(1, specqbft.FirstRound) for i := 0; i < maxProposerPreferencesDistinctRoots; i++ { r := root(byte(i + 1)) @@ -214,11 +214,12 @@ func TestValidatePartialSignatureMessageLimit_ProposerPreferences(t *testing.T) record(ss, peerA, r) } + // A distinct root beyond the cap is rate-limited (IGNORE), not a provable violation (REJECT). var valErr Error err := validatePartialSignatureMessageLimit(ppMsg(root(99)), peerA, ss) require.ErrorIs(t, err, ErrTooManyPartialSigMessage) require.True(t, errors.As(err, &valErr)) - require.True(t, valErr.reject) + require.False(t, valErr.reject) }) t.Run("same-peer duplicate root is rejected, a relayed duplicate is ignored", func(t *testing.T) { From 3ea0973477e0a489c7203aeb2c37e9279d73e71d Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 8 Jul 2026 16:21:02 +0300 Subject: [PATCH 110/150] gloas: align PTC duty-limit and proposer value-check with the SIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two impl-vs-spec gaps surfaced by the SIP §7 cross-check of the message-validation / QBFT value-check for the new Gloas roles: - PTC duty-limit: was capped at SlotsPerEpoch (grouped with proposer-preferences and self-build envelopes on a mistaken "one per slot" basis). A PTC member is drawn from a beacon committee and a validator sits on exactly one beacon committee per epoch, so it has at most one PTC duty per epoch. Cap it at 2 (one duty + reorg margin), matching aggregation / validator registration. - Proposer value-check: reject a value on a Gloas slot carrying a pre-Gloas Version. ssv-spec's ProposerValueCheckF branches to Gloas on cd.Version while the node branches on the slot; without the guard a Byzantine leader could split the value-check across a mixed ssv/anchor cluster. Honest proposers always stamp Version == the slot's fork, so the guard only rejects malformed values (the reverse, a Gloas Version on a pre-Gloas slot, is already rejected via GetBlockData's unknown-version error). Also correct the duty-limit rule summary: voluntary exit uses a tracked count (not a fixed 2), and self-build envelopes share the SlotsPerEpoch cap. --- message/validation/common_checks.go | 14 +++++++++----- message/validation/ptc_attester_test.go | 5 +++-- protocol/v2/ssv/value_check.go | 10 ++++++++++ protocol/v2/ssv/value_check_test.go | 21 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 1c9c8ae2af..af1fa9df7a 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -105,9 +105,10 @@ func (mv *messageValidator) validateDutyCount( } // Rule: valid number of duties per epoch: - // - 2 for aggregation, voluntary exit and validator registration + // - 2 for aggregation, validator registration and PTC attestation + // - the tracked exit-duty count for voluntary exit // - 2*V for Committee and AggregatorCommittee duty (where V is the number of validators in the cluster) (if no validator is doing sync committee in this epoch) - // - SlotsPerEpoch for proposer preferences + // - SlotsPerEpoch for proposer preferences and self-build envelopes // - else, accept if dutyCount > dutyLimit { e := ErrTooManyDutiesPerEpoch @@ -127,7 +128,10 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return mv.dutyStore.VoluntaryExit.GetDutyCount(slot, pk), true - case ssvtypes.RoleAggregator, spectypes.RoleValidatorRegistration: + case ssvtypes.RoleAggregator, spectypes.RoleValidatorRegistration, spectypes.RolePTCAttester: + // 2 = one duty per epoch plus a reorg margin. A PTC member is drawn from a beacon committee, and a + // validator sits on exactly one beacon committee per epoch, so it signs at most one payload + // attestation per epoch — the same bound as aggregation and validator registration. return 2, true case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee: @@ -150,9 +154,9 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true - case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder, spectypes.RolePTCAttester: + case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences (and likewise - // self-build envelopes) per epoch; a PTC member likewise signs at most one payload attestation per slot. + // self-build envelopes) per epoch. return mv.netCfg.SlotsPerEpoch, true default: diff --git a/message/validation/ptc_attester_test.go b/message/validation/ptc_attester_test.go index e0f829d1a8..5cd0ce9fb6 100644 --- a/message/validation/ptc_attester_test.go +++ b/message/validation/ptc_attester_test.go @@ -13,14 +13,15 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// A PTC member signs at most one payload attestation per slot → at most SlotsPerEpoch per epoch. +// A PTC member is drawn from a beacon committee, and a validator sits on exactly one beacon committee +// per epoch → at most one PTC duty per epoch, plus a reorg margin → limit 2. func TestDutyLimit_PTCAttester(t *testing.T) { mv := &messageValidator{netCfg: networkconfig.TestNetwork} msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RolePTCAttester) limit, ok := mv.dutyLimit(msgID, 0, nil) require.True(t, ok) - require.Equal(t, mv.netCfg.SlotsPerEpoch, limit) + require.Equal(t, uint64(2), limit) } // PTC fires at the slot's 75% cutoff (a current-slot duty): fine around its slot, late for a slot well behind. diff --git a/protocol/v2/ssv/value_check.go b/protocol/v2/ssv/value_check.go index a3b0373e4d..225261fa76 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -392,6 +392,16 @@ func checkValidatorConsensusData( var gloasBlock *gloas.BeaconBlock if cd.Duty.Type == spectypes.BNRoleProposer && beaconConfig.IsGloasAtSlot(cd.Duty.Slot) { + // The leader-stamped Version must agree with the slot's fork. ssv-spec's ProposerValueCheckF + // branches to Gloas on cd.Version, whereas we branch on the slot; without this guard a value on a + // Gloas slot carrying a pre-Gloas Version would be accepted here (slot-based) but rejected there + // (version-based), splitting the value check across a mixed cluster. Reject the mismatch so both + // bases agree — honest proposers always stamp Version == the slot's fork. (The reverse, a Gloas + // Version on a pre-Gloas slot, takes the else branch and is rejected by GetBlockData's + // unknown-version error.) + if cd.Version < networkconfig.DataVersionGloas { + return cd, nil, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "value version does not match slot fork") + } // Gloas blocks have no spectypes block version, so ValidateConsensusData's GetBlockData path // can't decode them; a successful node-side decode is the validity check. block, err := gloas.DecodeBeaconBlock(cd.DataSSZ) diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index a0576cc9c1..43bb9fbd67 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -252,6 +252,27 @@ func TestProposerChecker_GloasBlockSlotMismatch(t *testing.T) { require.ErrorContains(t, err, "does not match duty slot") } +// A value on a Gloas slot carrying a pre-Gloas Version is rejected: our slot-based branch and ssv-spec's +// version-based ProposerValueCheckF must agree on the fork, so a Byzantine leader can't split the value +// check across a mixed cluster. Honest proposers always stamp Version == the slot's fork; here only the +// Version is wrong (the block itself is a valid Gloas block for the duty slot). +func TestProposerChecker_GloasVersionMismatch(t *testing.T) { + checker := newGloasProposerChecker(fakeSlashingSigner{}) + cd := &spectypes.ProposerConsensusData{ + Duty: spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposer, + PubKey: gloasProposerPK, + ValidatorIndex: 7, + Slot: gloasProposerSlot, + }, + Version: networkconfig.DataVersionGloas - 1, // Fulu on a Gloas slot + DataSSZ: gloasBlockSSZ(t, gloasProposerSlot), + } + value, err := cd.Encode() + require.NoError(t, err) + require.ErrorContains(t, checker.CheckValue(value), "does not match slot fork") +} + // --- envelope checker, §6 --- var envelopeValidatorPK = phase0.BLSPubKey{0x42} From 40d1283b6530e20a00933cb15b48bd86a8cee539 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 15 Jul 2026 15:45:54 +0300 Subject: [PATCH 111/150] =?UTF-8?q?gloas:=20flag=20=C2=A75=20submit=20404?= =?UTF-8?q?=20as=20a=20missing=20beacon-API=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled Gloas requests (ptcDo) now surface non-2xx responses as a typed *httpStatusError — same message, but callers can branch on the status code. The §5 proposer-preferences submit uses it to wrap a 404 with a missing-endpoint hint: a beacon node predating the merged beacon-APIs#608 route (e.g. Lodestar releases through v1.44.0, which only serve an earlier draft path) 404s the final POST /eth/v1/validator/proposer_preferences, and the duty-failure log now says so instead of looking like a transient submit failure. --- beacon/goclient/proposer_preferences.go | 10 +++++++- beacon/goclient/proposer_preferences_test.go | 25 ++++++++++++++++++++ beacon/goclient/ptc.go | 17 ++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index b7c2bc7b7d..3192df042c 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -3,6 +3,7 @@ package goclient import ( "context" "encoding/json" + "errors" "fmt" "net/http" @@ -64,11 +65,18 @@ func (gc *GoClient) SubmitProposerPreferences(ctx context.Context, preferences [ } // submitProposerPreferences POSTs the signed proposer preferences as a JSON array to the validator endpoint. +// A 404 is flagged as a missing route — a BN build predating the merged beacon-APIs#608 endpoint (e.g. +// Lodestar releases through v1.44.0 only serve a draft path) — rather than a transient failure. func submitProposerPreferences(ctx context.Context, httpClient *http.Client, addr string, preferences []*gloas.SignedProposerPreferences) error { body, err := json.Marshal(preferences) if err != nil { return fmt.Errorf("marshal proposer preferences: %w", err) } headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} - return ptcDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) + err = ptcDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) + var statusErr *httpStatusError + if errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound { + return fmt.Errorf("beacon node lacks the gloas proposer_preferences endpoint (beacon-APIs#608): %w", err) + } + return err } diff --git a/beacon/goclient/proposer_preferences_test.go b/beacon/goclient/proposer_preferences_test.go index 0501227e7f..3d3c896474 100644 --- a/beacon/goclient/proposer_preferences_test.go +++ b/beacon/goclient/proposer_preferences_test.go @@ -50,6 +50,31 @@ func TestSubmitProposerPreferences(t *testing.T) { require.JSONEq(t, string(want), string(gotBody)) } +// A 404 — a BN build without the route (predating the merged beacon-APIs#608 endpoint) — is +// flagged as a missing endpoint rather than a transient failure. +func TestSubmitProposerPreferencesMissingRoute(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":404,"message":"Route POST:/eth/v1/validator/proposer_preferences not found"}`, http.StatusNotFound) + })) + defer srv.Close() + + err := submitProposerPreferences(context.Background(), srv.Client(), srv.URL, nil) + require.ErrorContains(t, err, "beacon node lacks the gloas proposer_preferences endpoint") + require.ErrorContains(t, err, "status 404") +} + +// Non-404 failures surface unchanged — no missing-endpoint flag. +func TestSubmitProposerPreferencesOtherStatusUnflagged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":500,"message":"internal"}`, http.StatusInternalServerError) + })) + defer srv.Close() + + err := submitProposerPreferences(context.Background(), srv.Client(), srv.URL, nil) + require.ErrorContains(t, err, "status 500") + require.NotContains(t, err.Error(), "beacon node lacks") +} + func TestRequestProposerDutiesDependentRoot(t *testing.T) { want := phase0.Root{0xde, 0xad, 0xbe, 0xef} diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index fea2725a48..26b2d8d8be 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -127,8 +127,23 @@ func submitPayloadAttestationMessages(ctx context.Context, httpClient *http.Clie return ptcDo(ctx, httpClient, http.MethodPost, addr+payloadAttestationsPath, body, headers, nil) } +// httpStatusError is a non-2xx response to a hand-rolled Gloas request. It keeps the status code +// so callers can tell a missing route (404 — a BN build without the endpoint) from a transient +// failure. The "METHOD URL: status N: body" message format is pinned by tests. +type httpStatusError struct { + method string + url string + status int + body string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("%s %s: status %d: %s", e.method, e.url, e.status, e.body) +} + // ptcDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil // to ignore the body). A nil body sends no request payload; extraHeaders are applied last. +// Non-2xx responses surface as *httpStatusError. func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { var reader io.Reader if body != nil { @@ -157,7 +172,7 @@ func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, bod return fmt.Errorf("read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBody))) + return &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} } if out != nil { if err := json.Unmarshal(respBody, out); err != nil { From 3cc4171a07b85e40207b7b8b6d32e50895b2baff Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 15 Jul 2026 15:46:15 +0300 Subject: [PATCH 112/150] =?UTF-8?q?gloas:=20fix=20=C2=A75=20pre-consensus?= =?UTF-8?q?=20starvation=20across=20skewed=20emission=20ticks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators broadcast their §5 partial signature exactly once, at their own emission tick, and those ticks skew across the committee (registration/event-sync timing). A partial arriving before the local duty (re)started was only replayed by the queue for ~1 slot and then dropped — unrecoverably, since the sender never re-broadcasts and message validation dedups a same-root re-broadcast by signing root. Every indices-change re-emission also replaced the sub-runner and discarded the partials it had collected. On a devnet this starved every first-§5-epoch duty (the Gloas fork-boundary epoch) on all operators: each node held fewer than quorum partials and logged 'duty did not complete before slot end'. Runner (dispatcher): - stash every §5 partial per proposal slot (bounded by committee × the wire's distinct-root cap, deduplicated, pruned with past slots) and replay the stash into every (re)started sub-runner, so quorum forms regardless of emission order and container replacement. - conclude a replaced sub-runner as not-required (superseded, not stuck), and carry the already-submitted preference over so an unchanged re-emission is idempotent: no duplicate gossip broadcast (peers would reject it) and no duplicate beacon-node submit; a dependent_root change still re-signs, re-broadcasts and resubmits. Outcome watcher: a §5 duty legitimately keeps converging until its proposal slot, so its stuck horizon is that slot's start rather than the emission slot's end (which produced false 'stuck' warnings for duties that could still complete). Scheduler: - give each §5 duty an execution window running to the end of its own proposal slot (was: end of the emission slot), matching the watcher. - skip assignments whose proposal slot is already reached — their preference is moot and peers would reject the partials as late. - force a one-time lookahead recheck on the first Gloas tick, like a reorg would: if the CL's reported dependent_root for the boundary epoch shifts at the fork transition, the pre-fork emission is refreshed; with an unchanged root the recheck is a no-op. --- operator/duties/proposer_preferences.go | 31 +++- operator/duties/proposer_preferences_test.go | 99 +++++++++- operator/duties/ptc_attestation_test.go | 11 +- .../v2/ssv/runner/proposer_preferences.go | 115 +++++++++++- .../ssv/runner/proposer_preferences_test.go | 171 ++++++++++++++++++ protocol/v2/ssv/runner/runner.go | 13 +- .../v2/ssv/runner/runner_deadline_test.go | 19 ++ 7 files changed, 438 insertions(+), 21 deletions(-) diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index 8af4eec139..dd28c460e1 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -26,6 +26,10 @@ type ProposerPreferencesHandler struct { // recheckLookahead is set by a reorg so the next tick re-evaluates each lookahead epoch's // dependent_root instead of trusting its emitted marker. Accessed only from the HandleDuties goroutine. recheckLookahead bool + + // gloasForkRechecked is set once the first Gloas tick has forced a lookahead recheck (see + // emitForTick). Accessed only from the HandleDuties goroutine. + gloasForkRechecked bool } func NewProposerPreferencesHandler() *ProposerPreferencesHandler { @@ -85,6 +89,14 @@ func (h *ProposerPreferencesHandler) emitForTick(ctx context.Context, slot phase epoch := h.netCfg.EstimatedEpochAtSlot(slot) switch { case h.netCfg.IsGloas(epoch): + // The first Gloas tick forces a recheck, like a reorg would: the pre-fork window emitted the + // fork epoch under the pre-fork view of its dependent_root, and a CL whose reported root + // shifts at the fork transition would otherwise leave the boundary epoch pinned to the stale + // root (an epoch re-emits only on a root change). With an unchanged root this is a no-op. + if !h.gloasForkRechecked { + h.gloasForkRechecked = true + recheck = true + } h.emitForEpoch(ctx, epoch, slot, recheck) if h.shouldFetchNextEpoch(slot) { h.emitForEpoch(ctx, epoch+1, slot, recheck) @@ -96,8 +108,8 @@ func (h *ProposerPreferencesHandler) emitForTick(ctx context.Context, slot phase } } -// emitForEpoch emits one proposer-preferences duty per local proposal assignment in the epoch, to be -// broadcast immediately. It emits once per (epoch, dependent_root): a steady-state tick skips an +// emitForEpoch emits one proposer-preferences duty per still-upcoming local proposal assignment in +// the epoch, to be broadcast immediately. It emits once per (epoch, dependent_root): a steady-state tick skips an // already-emitted epoch, and a post-reorg recheck re-emits only when the epoch's dependent_root changed — // re-emitting under an unchanged root would just duplicate the preference and get the operator // gossip-penalized (SIP #94 §5). @@ -128,6 +140,12 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha preferenceDuties := make([]*spectypes.ValidatorDuty, 0, len(duties)) for _, d := range duties { + if d.Slot <= currentSlot { + // The epoch fetch returns every assignment in the epoch, including proposal slots already + // reached: a preference for those is moot (builders needed it beforehand) and peers would + // reject its partials as late, so emitting it could only produce a doomed duty. + continue + } preferenceDuties = append(preferenceDuties, &spectypes.ValidatorDuty{ Type: spectypes.BNRoleProposerPreferences, PubKey: d.PubKey, @@ -142,9 +160,12 @@ func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch pha } // Emit now: the runner builds, signs, and broadcasts immediately. duty.Slot is the (future) - // proposal slot, so bound execution by the current slot, not duty.Slot. - deadline := h.netCfg.SlotStartTime(currentSlot + 1) - h.dutiesExecutor.ExecuteDuties(ctx, preferenceDuties, deadline) + // proposal slot, and operators emit at their own ticks (registration/event timing), so §5 + // convergence may legitimately span the slots up to it — give each duty an execution window + // running to the end of its own proposal slot, matching the runner's §5 outcome horizon. + for _, d := range preferenceDuties { + h.dutiesExecutor.ExecuteDuties(ctx, []*spectypes.ValidatorDuty{d}, h.netCfg.SlotStartTime(d.Slot+1)) + } h.logger.Debug("emitted proposer preferences duties", fields.Epoch(epoch), diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index 8fc204a4dc..b0fc38f46c 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -3,14 +3,16 @@ package duties import ( "context" "testing" + "time" eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" - spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "go.uber.org/zap" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/types" ) @@ -106,6 +108,97 @@ func TestProposerPreferencesHandler_emitForEpoch_noProposals(t *testing.T) { require.Len(t, executed, 0) } +// emitForEpoch skips assignments whose proposal slot has already been reached (the preference is +// moot and peers would reject its partials as late) and bounds each remaining duty by its own +// proposal slot's end — §5 convergence legitimately spans the slots up to it. +func TestProposerPreferencesHandler_emitForEpoch_skipsReachedSlotsAndBoundsPerDuty(t *testing.T) { + ctrl := gomock.NewController(t) + + epoch := phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + currentSlot := phase0.Slot(40) + pk := phase0.BLSPubKey{1, 2, 3} + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(epoch). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}).Times(1) + + bn := NewMockBeaconNode(ctrl) + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), epoch).Return(phase0.Root{0xaa}, nil).Times(1) + bn.EXPECT().ProposerDuties(gomock.Any(), epoch, []phase0.ValidatorIndex{idx}). + Return([]*eth2apiv1.ProposerDuty{ + {PubKey: pk, ValidatorIndex: idx, Slot: currentSlot - 1}, // passed: skipped + {PubKey: pk, ValidatorIndex: idx, Slot: currentSlot}, // reached: skipped + {PubKey: pk, ValidatorIndex: idx, Slot: currentSlot + 5}, // upcoming: emitted + }, nil).Times(1) + + executed := make(chan []*spectypes.ValidatorDuty, 3) + deadlines := make(chan time.Time, 3) + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.netCfg = networkconfig.TestNetwork + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed, deadlines: deadlines} + + h.emitForEpoch(context.Background(), epoch, currentSlot, false) + + require.Len(t, executed, 1, "only the upcoming assignment is emitted") + got := <-executed + require.Len(t, got, 1) + require.Equal(t, currentSlot+5, got[0].Slot) + require.Equal(t, networkconfig.TestNetwork.SlotStartTime(currentSlot+5+1), <-deadlines, + "each duty's execution window runs to the end of its own proposal slot") +} + +// The first Gloas tick forces a one-time lookahead recheck: a boundary epoch emitted pre-fork under +// a dependent_root that shifts at the fork transition is re-emitted under the fresh root; later +// Gloas ticks don't recheck again (the pinned mock call order fails on any extra fetch). +func TestProposerPreferencesHandler_firstGloasTickRechecksBoundaryEpoch(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + + ctrl := gomock.NewController(t) + idx := phase0.ValidatorIndex(7) + pk := phase0.BLSPubKey{1, 2, 3} + proposalSlot := phase0.Slot(uint64(gloasEpoch)*netCfg.SlotsPerEpoch) + 10 + rootA, rootB := phase0.Root{0xaa}, phase0.Root{0xbb} + + vp := NewMockValidatorProvider(ctrl) + vp.EXPECT().SelfParticipatingValidators(phase0.Epoch(gloasEpoch)). + Return([]*types.SSVShare{{Share: spectypes.Share{ValidatorIndex: idx, ValidatorPubKey: spectypes.ValidatorPK(pk)}}}).AnyTimes() + + duty := []*eth2apiv1.ProposerDuty{{PubKey: pk, ValidatorIndex: idx, Slot: proposalSlot}} + bn := NewMockBeaconNode(ctrl) + gomock.InOrder( + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), phase0.Epoch(gloasEpoch)).Return(rootA, nil), // pre-fork window emit + bn.EXPECT().ProposerDuties(gomock.Any(), phase0.Epoch(gloasEpoch), []phase0.ValidatorIndex{idx}).Return(duty, nil), + bn.EXPECT().ProposerDutiesDependentRoot(gomock.Any(), phase0.Epoch(gloasEpoch)).Return(rootB, nil), // fork-tick recheck: root shifted + bn.EXPECT().ProposerDuties(gomock.Any(), phase0.Epoch(gloasEpoch), []phase0.ValidatorIndex{idx}).Return(duty, nil), + ) + + executed := make(chan []*spectypes.ValidatorDuty, 2) + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.netCfg = netCfg + h.validatorProvider = vp + h.beaconNode = bn + h.dutiesExecutor = &captureExecutor{executed: executed} + + preForkSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) + forkSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) + + h.emitForTick(context.Background(), preForkSlot) // pre-fork window: emits under rootA + require.Equal(t, rootA, h.emitted[phase0.Epoch(gloasEpoch)]) + + h.emitForTick(context.Background(), forkSlot) // first Gloas tick: forced recheck re-emits under rootB + require.Equal(t, rootB, h.emitted[phase0.Epoch(gloasEpoch)]) + + h.emitForTick(context.Background(), forkSlot+1) // second Gloas tick: no recheck, no re-fetch + + require.Len(t, executed, 2, "pre-fork emit and the fork-tick re-emit only") +} + // evictOutdated drops only epochs strictly before the current one. func TestProposerPreferencesHandler_evictOutdated(t *testing.T) { h := NewProposerPreferencesHandler() @@ -139,7 +232,9 @@ func TestProposerPreferencesHandler_emitForTick(t *testing.T) { ctrl := gomock.NewController(t) idx := phase0.ValidatorIndex(7) pk := phase0.BLSPubKey{1, 2, 3} - proposalSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) // a slot in the Gloas fork epoch + // A still-upcoming slot in the Gloas fork epoch: the steady-state tick sits on the epoch's + // first slot, and an assignment at the tick slot itself is filtered as already reached. + proposalSlot := phase0.Slot(uint64(gloasEpoch)*netCfg.SlotsPerEpoch) + 1 vp := NewMockValidatorProvider(ctrl) vp.EXPECT().SelfParticipatingValidators(phase0.Epoch(gloasEpoch)). diff --git a/operator/duties/ptc_attestation_test.go b/operator/duties/ptc_attestation_test.go index 335040c24e..d866561453 100644 --- a/operator/duties/ptc_attestation_test.go +++ b/operator/duties/ptc_attestation_test.go @@ -18,13 +18,18 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// captureExecutor records the duties handed to ExecuteDuties so a test can assert on them. +// captureExecutor records the duties handed to ExecuteDuties (and, when a deadlines channel is set, +// each call's duty deadline) so a test can assert on them. type captureExecutor struct { - executed chan []*spectypes.ValidatorDuty + executed chan []*spectypes.ValidatorDuty + deadlines chan time.Time } -func (c *captureExecutor) ExecuteDuties(_ context.Context, duties []*spectypes.ValidatorDuty, _ time.Time) { +func (c *captureExecutor) ExecuteDuties(_ context.Context, duties []*spectypes.ValidatorDuty, deadline time.Time) { c.executed <- duties + if c.deadlines != nil { + c.deadlines <- deadline + } } func (c *captureExecutor) ExecuteCommitteeDuties(context.Context, committeeDutiesMap, time.Time) {} diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 42b5ce55c6..09372556a2 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -40,8 +40,22 @@ type ProposerPreferencesRunner struct { // bySlot holds one sub-runner per concurrently-active proposal slot. Accessed only from the // validator's single message-processing goroutine. bySlot map[phase0.Slot]*proposerPreferencesSlotRunner + + // pending stashes every §5 partial-signature message by proposal slot so StartNewDuty can replay + // it into a new (or replacement) sub-runner. Operators broadcast their §5 partial exactly once, at + // their own emission tick, and those ticks skew across the committee (registration/event-sync + // timing); a partial that arrives before the local duty (re)starts would otherwise be lost — the + // sender does not re-broadcast, and message validation would drop a same-root re-broadcast as a + // duplicate anyway. Bounded per slot (committee × distinct-root cap), pruned with evictPastSlots. + // Accessed only from the validator's single message-processing goroutine. + pending map[phase0.Slot][]*spectypes.PartialSignatureMessages } +// maxPendingRootsPerSigner mirrors message validation's maxProposerPreferencesDistinctRoots: the wire +// admits at most that many distinct §5 signing roots per (slot, signer), so the pending stash never +// needs to retain more per signer. +const maxPendingRootsPerSigner = 4 + // ProposerPreferencesRunnerOptions bundles the dependencies required by NewProposerPreferencesRunner. type ProposerPreferencesRunnerOptions struct { BaseRunnerOptions @@ -61,8 +75,9 @@ func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner NetworkConfig: opts.NetworkConfig, Share: opts.Share, }, - opts: opts, - bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, + opts: opts, + bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, + pending: map[phase0.Slot][]*spectypes.PartialSignatureMessages{}, }, nil } @@ -74,23 +89,77 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za r.evictPastSlots() - // One sub-runner per proposal slot; a re-emission for the same slot (e.g. after a reorg) replaces - // the prior one so it freezes the new dependent_root. + // One sub-runner per proposal slot; a re-emission for the same slot (e.g. after a reorg, or an + // indices-change re-emit) replaces the prior one so it freezes the current dependent_root. The + // replaced incarnation is concluded so its outcome watcher doesn't report a false "stuck", and its + // submitted preference carries over so an unchanged re-emission stays idempotent (no duplicate + // broadcast or beacon-node submit; see executeDuty). + slot := validatorDuty.DutySlot() sub := newProposerPreferencesSlotRunner(r.opts) - r.bySlot[validatorDuty.DutySlot()] = sub - return sub.StartNewDuty(ctx, logger, duty, quorum) + if prev, ok := r.bySlot[slot]; ok { + sub.submittedPreferences = prev.submittedPreferences + if prev.hasDutyRunning() { + prev.markDutyNotRequired() // superseded by the re-emission, not stuck + } + } + r.bySlot[slot] = sub + if err := sub.StartNewDuty(ctx, logger, duty, quorum); err != nil { + return err + } + + // Replay the stashed partials for this proposal slot. Peers broadcast their §5 partial once, at + // their own emission tick, so it may predate this (re)start; the stash is the only recovery path — + // there is no re-broadcast, and message validation would dedup one by signing root anyway. A + // stashed partial that doesn't match the freshly frozen preference fails signature verification + // inside the sub-runner and is skipped. + if sub.hasDutyRunning() { + for _, stashed := range r.pending[slot] { + if err := sub.ProcessPreConsensus(ctx, logger, stashed); err != nil { + logger.Debug("skipped stashed proposer-preferences partial on replay", + fields.Slot(slot), zap.Error(err)) + } + } + } + return nil } func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + // Stash every §5 partial (bounded, deduplicated), even when a sub-runner exists: a later + // re-emission replaces the sub-runner and its container, and peers won't re-broadcast, so the + // stash is what re-seeds the replacement (see StartNewDuty). + r.stashPending(signedMsg) + sub, ok := r.bySlot[signedMsg.Slot] if !ok { - // No sub-runner for this proposal slot — it hasn't executed here yet, or it already concluded - // and was evicted. Retryable so a slightly-early peer message lands once the duty starts. + // No sub-runner for this proposal slot — it hasn't executed here yet (the stash above replays + // once it starts), or it already concluded and was evicted. Retryable so a message racing the + // duty start also lands via the queue replay. return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) } return sub.ProcessPreConsensus(ctx, logger, signedMsg) } +// stashPending records a §5 partial for its proposal slot so StartNewDuty can replay it. Duplicates +// by (signer, signing root) are skipped; a slot's stash is capped at the committee size times the +// wire's per-signer distinct-root cap, so a full stash can only mean noise. +func (r *ProposerPreferencesRunner) stashPending(signedMsg *spectypes.PartialSignatureMessages) { + if signedMsg == nil || len(signedMsg.Messages) != 1 { + return // §5 partials carry exactly one message (enforced by message validation) + } + msg := signedMsg.Messages[0] + stash := r.pending[signedMsg.Slot] + for _, existing := range stash { + e := existing.Messages[0] + if e.Signer == msg.Signer && e.SigningRoot == msg.SigningRoot { + return + } + } + if len(stash) >= len(r.GetShare().Committee)*maxPendingRootsPerSigner { + return + } + r.pending[signedMsg.Slot] = append(stash, signedMsg) +} + func (r *ProposerPreferencesRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { return fmt.Errorf("no consensus phase for proposer preferences") } @@ -110,8 +179,8 @@ func (r *ProposerPreferencesRunner) HasRunningDuty() bool { return false } -// evictPastSlots drops sub-runners whose proposal slot has passed; the preference is moot once the -// proposal slot arrives, and convergence completes well before it. +// evictPastSlots drops sub-runners (and stashed partials) whose proposal slot has passed; the +// preference is moot once the proposal slot arrives, and convergence completes well before it. func (r *ProposerPreferencesRunner) evictPastSlots() { current := r.NetworkConfig.EstimatedCurrentSlot() for slot := range r.bySlot { @@ -119,6 +188,11 @@ func (r *ProposerPreferencesRunner) evictPastSlots() { delete(r.bySlot, slot) } } + for slot := range r.pending { + if slot < current { + delete(r.pending, slot) + } + } } func (r *ProposerPreferencesRunner) GetNetwork() protocolp2p.Network { return r.opts.Network } @@ -169,6 +243,9 @@ func (r *ProposerPreferencesRunner) UnmarshalJSON(data []byte) error { if r.bySlot == nil { r.bySlot = map[phase0.Slot]*proposerPreferencesSlotRunner{} } + if r.pending == nil { + r.pending = map[phase0.Slot][]*spectypes.PartialSignatureMessages{} + } return nil } @@ -215,6 +292,13 @@ type proposerPreferencesSlotRunner struct { // are validated and aggregated against exactly this object's signing root; nil means the duty has // not executed yet. proposerPreferences *gloas.ProposerPreferences + + // submittedPreferences is the preference this proposal slot already submitted to the beacon node, + // carried across sub-runner replacements by the dispatcher. executeDuty compares against it so a + // re-emission that would rebuild the exact same preference (e.g. an indices-change re-emit under an + // unchanged dependent_root) concludes as not-required instead of duplicating the gossip broadcast + // (peers dedup it by signing root) and the beacon-node submit. + submittedPreferences *gloas.ProposerPreferences } func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { @@ -294,6 +378,7 @@ func (r *proposerPreferencesSlotRunner) ProcessPreConsensus(ctx context.Context, } recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(r.proposerPreferences.ProposalSlot), spectypes.BNRoleProposerPreferences) + r.submittedPreferences = r.proposerPreferences r.markDutySucceeded() logger.Info("✔️ successfully submitted proposer preferences", fields.Slot(r.proposerPreferences.ProposalSlot)) return nil @@ -334,6 +419,16 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger return nil } + if r.submittedPreferences != nil && *preferences == *r.submittedPreferences { + // A re-emission rebuilt the exact preference this slot already submitted: nothing changed, so + // re-signing it would only produce a duplicate broadcast (dropped by peers' signing-root dedup) + // and a duplicate beacon-node submit. Conclude quietly. + logger.Debug("proposer preferences unchanged since last successful submit; skipping re-emission", + fields.Slot(proposalSlot)) + r.markDutyNotRequired() + return nil + } + // Freeze the observation: peers' partial signatures are validated and aggregated against exactly // this object's signing root, so only operators that converged on identical preferences (same // dependent_root, fee recipient, gas limit) reach quorum. diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index 75acaa063a..70db2c2df4 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -9,11 +9,15 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" spectypes "github.com/ssvlabs/ssv-spec/types" + spectestingutils "github.com/ssvlabs/ssv-spec/types/testingutils" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" ) type errFeeRecipientProvider struct{} @@ -22,6 +26,31 @@ func (errFeeRecipientProvider) GetFeeRecipient(spectypes.ValidatorPK) (bellatrix return bellatrix.ExecutionAddress{}, fmt.Errorf("no fee recipient") } +// fixedFeeRecipientProvider returns the same fee recipient for every validator, so every "operator" +// in a test freezes byte-identical preferences. +type fixedFeeRecipientProvider struct{ addr bellatrix.ExecutionAddress } + +func (p fixedFeeRecipientProvider) GetFeeRecipient(spectypes.ValidatorPK) (bellatrix.ExecutionAddress, error) { + return p.addr, nil +} + +// prefsTestBeacon embeds the spec testing beacon (so DomainData resolves) while stubbing the §5 +// surface: a settable dependent root and a capture of submitted preferences. +type prefsTestBeacon struct { + beacon.BeaconNode + dependentRoot phase0.Root + submitted [][]*gloas.SignedProposerPreferences +} + +func (b *prefsTestBeacon) ProposerDutiesDependentRoot(context.Context, phase0.Epoch) (phase0.Root, error) { + return b.dependentRoot, nil +} + +func (b *prefsTestBeacon) SubmitProposerPreferences(_ context.Context, prefs []*gloas.SignedProposerPreferences) error { + b.submitted = append(b.submitted, prefs) + return nil +} + func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { _, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{}) require.Error(t, err) @@ -97,6 +126,148 @@ func TestProposerPreferencesRunner_ProcessPreConsensus_unknownSlot(t *testing.T) require.True(t, IsRetryable(err)) } +// stashPending dedups by (signer, signing root), caps a slot's stash at committee size times the +// per-signer distinct-root cap, and evictPastSlots prunes stashed slots alongside sub-runners. +func TestProposerPreferencesRunner_stashPending(t *testing.T) { + netCfg := networkconfig.TestNetwork + committee := make([]*spectypes.ShareMember, 2) // stash cap = 2 * maxPendingRootsPerSigner + r, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: netCfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {Committee: committee}}, + }, + }) + require.NoError(t, err) + disp := r.(*ProposerPreferencesRunner) + + slot := netCfg.EstimatedCurrentSlot() + 10 + msg := func(signer spectypes.OperatorID, root byte) *spectypes.PartialSignatureMessages { + return &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: slot, + Messages: []*spectypes.PartialSignatureMessage{{Signer: signer, SigningRoot: [32]byte{root}}}, + } + } + + disp.stashPending(msg(1, 0xaa)) + disp.stashPending(msg(1, 0xaa)) // duplicate (signer, root): skipped + disp.stashPending(msg(2, 0xaa)) // same root, another signer: kept + disp.stashPending(msg(1, 0xbb)) // same signer, another root: kept + require.Len(t, disp.pending[slot], 3) + + for i := range 16 { // well beyond the cap + disp.stashPending(msg(spectypes.OperatorID(10+i), 0xcc)) + } + require.Len(t, disp.pending[slot], 2*maxPendingRootsPerSigner) + + disp.pending[netCfg.EstimatedCurrentSlot()-1] = disp.pending[slot] // a stale slot + disp.evictPastSlots() + require.NotContains(t, disp.pending, netCfg.EstimatedCurrentSlot()-1) + require.Contains(t, disp.pending, slot) +} + +// End-to-end §5 convergence across emission skew: operators broadcast their partial exactly once, at +// their own emission tick, so peers' partials can precede the local duty (or a replacement of it). +// The dispatcher stashes every partial and replays it into a (re)started sub-runner so quorum still +// forms; an unchanged re-emission after a successful submit concludes idempotently (no duplicate +// broadcast or submit); a dependent_root change re-emits and awaits fresh partials. +func TestProposerPreferencesRunner_stashReplayConvergence(t *testing.T) { + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + cfg := cloneTestNetworkConfig() + const quorum = 3 + const gasLimit = 36_000_000 + + bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} + network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) + feeRecipient := bellatrix.ExecutionAddress{0xfe} + + runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: network, + Signer: ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()), + OperatorSigner: spectestingutils.NewOperatorSigner(keySet, 1), + }, + FeeRecipientProvider: fixedFeeRecipientProvider{addr: feeRecipient}, + GasLimit: gasLimit, + }) + require.NoError(t, err) + disp := runnerIface.(*ProposerPreferencesRunner) + + proposalSlot := cfg.EstimatedCurrentSlot() + 5 + duty := &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + } + + // peerPartial signs the preference every operator is expected to converge on, as peer opID. + peerPartial := func(t *testing.T, opID spectypes.OperatorID, dependentRoot phase0.Root) *spectypes.PartialSignatureMessages { + t.Helper() + prefs := &gloas.ProposerPreferences{ + DependentRoot: dependentRoot, + ProposalSlot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + FeeRecipient: feeRecipient, + TargetGasLimit: gasLimit, + } + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainProposerPreferences)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(prefs, domain) + require.NoError(t, err) + sig := keySet.Shares[opID].SignByte(root[:]) + return &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: opID, + ValidatorIndex: share.ValidatorIndex, + }}, + } + } + + ctx := context.Background() + logger := zap.NewNop() + + // Peers 2..4 emitted before us: their one-shot partials arrive with no local duty and are stashed. + for _, op := range []spectypes.OperatorID{2, 3, 4} { + err := disp.ProcessPreConsensus(ctx, logger, peerPartial(t, op, bn.dependentRoot)) + require.Error(t, err) + require.True(t, IsRetryable(err)) + } + + // Our own (late) emission: the replay of the stashed partials completes quorum and submits. + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + require.Len(t, bn.submitted, 1, "stashed partials must be replayed to quorum on duty start") + require.Len(t, network.BroadcastedMsgs, 1, "own partial broadcast exactly once") + require.Equal(t, phase0.Root{0xaa}, bn.submitted[0][0].Message.DependentRoot) + + // An unchanged re-emission (e.g. an indices-change re-emit under the same root) is idempotent. + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + require.Len(t, bn.submitted, 1, "unchanged re-emission must not resubmit") + require.Len(t, network.BroadcastedMsgs, 1, "unchanged re-emission must not re-broadcast") + + // A dependent_root change re-emits: fresh broadcast; the stale-root stashed partials fail + // verification against the new frozen preference and must not complete its quorum. + bn.dependentRoot = phase0.Root{0xbb} + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + require.Len(t, network.BroadcastedMsgs, 2, "root change must re-broadcast a fresh partial") + require.Len(t, bn.submitted, 1, "stale-root partials must not complete the new quorum") + + // The peers' new-root partials arrive live; quorum re-forms and the updated preference submits. + for _, op := range []spectypes.OperatorID{2, 3, 4} { + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerPartial(t, op, bn.dependentRoot))) + } + require.Len(t, bn.submitted, 2, "the re-emitted preference must submit once its quorum forms") + require.Equal(t, phase0.Root{0xbb}, bn.submitted[1][0].Message.DependentRoot) +} + // Proposer preferences have no consensus or post-consensus phase; those entry points must reject. func TestProposerPreferencesRunner_NoConsensusPhases(t *testing.T) { r := &ProposerPreferencesRunner{} diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index b6d277ba50..c5709b4a31 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -329,12 +329,23 @@ type dutyConclusion struct { // // The deadline is the end of the current wall-clock slot rather than duty.Slot's end because some // duties are stamped with a slot in the past (a voluntary-exit duty carries blockSlot+4 but -// executes at blockSlot+12); for beacon duties the two coincide. +// executes at blockSlot+12); for beacon duties the two coincide. Proposer preferences are the +// opposite case — duty.Slot is a future proposal slot and the duty executes at emission, so their +// horizon extends to that slot's start instead (see below). func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { concluded := make(chan dutyConclusion, 1) b.dutyConcluded = concluded deadline := b.NetworkConfig.SlotStartTime(b.NetworkConfig.EstimatedCurrentSlot() + 1) + // A proposer-preferences duty emits ahead of its proposal slot and legitimately keeps converging + // across the gap — operators broadcast their partials at their own emission ticks — so its outcome + // horizon is the proposal slot's start (the preference is moot once that slot arrives), not the + // end of the emission slot. + if b.RunnerRoleType == spectypes.RoleProposerPreferences && b.State != nil { + if d := b.NetworkConfig.SlotStartTime(b.State.CurrentDuty.DutySlot()); d.After(deadline) { + deadline = d + } + } report := func(c dutyConclusion) { recordDutyOutcome(ctx, b.GetRole(), c.outcome) diff --git a/protocol/v2/ssv/runner/runner_deadline_test.go b/protocol/v2/ssv/runner/runner_deadline_test.go index 8de1791ffc..60ecac896d 100644 --- a/protocol/v2/ssv/runner/runner_deadline_test.go +++ b/protocol/v2/ssv/runner/runner_deadline_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -48,6 +49,24 @@ func TestBaseRunner_watchDutyOutcome(t *testing.T) { }, time.Second, 5*time.Millisecond, "expected exactly one deadline warning") }) + t.Run("proposer preferences: stuck horizon extends to the proposal slot start", func(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + b := newRunner() + b.RunnerRoleType = spectypes.RoleProposerPreferences + // duty.Slot is a future proposal slot (slot 4 → its start is 4 slots away); the §5 duty keeps + // converging until then, so the current slot's end must not report it stuck. + b.State = &State{CurrentDuty: &spectypes.ValidatorDuty{Slot: 4}} + + b.watchDutyOutcome(context.Background(), zap.New(core)) + + time.Sleep(120 * time.Millisecond) // two slots past the emission slot's end + require.Zero(t, logs.FilterMessageSnippet(deadlineSnippet).Len(), "§5 must not report stuck before its proposal slot") + + require.Eventually(t, func() bool { + return logs.FilterMessageSnippet(deadlineSnippet).Len() == 1 + }, time.Second, 5*time.Millisecond, "expected the stuck warning at the proposal slot's start") + }) + t.Run("warns when the duty fails before slot end", func(t *testing.T) { core, logs := observer.New(zapcore.WarnLevel) b := newRunner() From 8b67488ad7ac5271a51e9ab8e3dbed4dd1510d4c Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 15 Jul 2026 23:20:15 +0300 Subject: [PATCH 113/150] =?UTF-8?q?gloas:=20tolerate=20=C2=A75/=C2=A76=20d?= =?UTF-8?q?uty-view=20staleness=20across=20validator-set=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A §5 partial is broadcast exactly once and its bytes are deterministic, so gossipsub's seen-cache makes any re-broadcast a no-op: a receiver that wrongly drops the first copy can never recover it. The wrong drop we observed: the §5 duty-existence rule evaluated against a proposer duty view fetched before the receiver processed a just-added validator's registration — honest partials rejected with 'no duty for this epoch', quorum starved for that proposal slot (seen on the first §5-eligible epoch right after a registration wave; the stash/replay mechanism can't help since the message dies before the queue). Two complementary changes: Validation freshness: the duty store now tracks per-epoch staleness — the ProposerHandler marks the current and next epoch stale the moment an indices change arrives, and a completed refetch (Set) clears it. The §5 proposer-preferences and §6 self-build-envelope duty-existence checks treat a stale epoch like a not-yet-fetched one (skip, don't reject); data keeps being served to checks that must always enforce assignment. During such windows §5/§6 messages remain bounded by committee membership, the per-signer distinct-root cap and the per-epoch duty-count cap. Emission grace: the §5 handler defers (re-)emission for two slots after a validator-set change, so committee peers learn of the new validators (contract-event sync) and refresh their duty views before the one-shot partials are broadcast. Preferences target future slots, so the delay costs nothing; a pending reorg/fork recheck survives the grace and is consumed by the first post-grace tick. --- message/validation/common_checks.go | 17 ++++++--- message/validation/envelope_builder_test.go | 31 ++++++++++++++- .../validation/proposer_preferences_test.go | 15 ++++++-- operator/duties/dutystore/duties.go | 38 ++++++++++++++++++- operator/duties/dutystore/duties_test.go | 30 +++++++++++++++ operator/duties/proposer.go | 6 +++ operator/duties/proposer_preferences.go | 28 ++++++++++++-- operator/duties/proposer_preferences_test.go | 33 ++++++++++++++++ 8 files changed, 184 insertions(+), 14 deletions(-) diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index af1fa9df7a..d641b3cc72 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -198,21 +198,26 @@ func (mv *messageValidator) validateBeaconDuty( } // Rule: For a proposer-preferences message, require a real proposer assignment for the validator at - // the slot — but only once the slot's epoch is fetched. Preferences ride a future proposal slot - // whose epoch may still be in flight; tolerate that (the earliness/lateness window bounds the slot). + // the slot — but only from a fetched AND fresh epoch. Preferences ride a future proposal slot whose + // epoch may still be in flight (tolerated; the earliness/lateness window bounds the slot), and an + // epoch fetched before the latest indices change is equally unusable for rejection: dropping a + // just-added validator's one-shot partial on a stale view starves its quorum permanently — an + // identical re-broadcast can't pass the gossip seen-cache (SIP #94 §5). if role == spectypes.RoleProposerPreferences { validatorIndex := indices[0] - if mv.dutyStore.Proposer.IsEpochSet(epoch) && mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { + if mv.dutyStore.Proposer.IsEpochSet(epoch) && !mv.dutyStore.Proposer.IsEpochStale(epoch) && + mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { return ErrNoDuty } } // The self-build envelope rides the proposer's slot, so it must carry a real proposer assignment — - // guarded by IsEpochSet like proposer-preferences, since the message can arrive before the epoch's - // duties are fetched. + // guarded like proposer-preferences (fetched and fresh), since the message can arrive before the + // epoch's duties are fetched or while a refetch for changed indices is in flight. if role == spectypes.RoleEnvelopeBuilder { validatorIndex := indices[0] - if mv.dutyStore.Proposer.IsEpochSet(epoch) && mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { + if mv.dutyStore.Proposer.IsEpochSet(epoch) && !mv.dutyStore.Proposer.IsEpochStale(epoch) && + mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { return ErrNoDuty } } diff --git a/message/validation/envelope_builder_test.go b/message/validation/envelope_builder_test.go index 08daac5b1c..0a01b4084c 100644 --- a/message/validation/envelope_builder_test.go +++ b/message/validation/envelope_builder_test.go @@ -3,12 +3,15 @@ package validation import ( "testing" + eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + specqbft "github.com/ssvlabs/ssv-spec/qbft" spectypes "github.com/ssvlabs/ssv-spec/types" - "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/operator/duties/dutystore" ) // The §6 envelope duty is QBFT with only a post-consensus partial signature (no pre-consensus phase). @@ -55,3 +58,29 @@ func TestMonotonicSlotRole_EnvelopeBuilder(t *testing.T) { mv := &messageValidator{} require.True(t, mv.monotonicSlotRole(spectypes.RoleEnvelopeBuilder)) } + +// An envelope message must carry a real proposer assignment once the slot's epoch is fetched AND +// fresh; unfetched and stale (mid-indices-change) epochs are tolerated, mirroring the +// proposer-preferences rule. +func TestValidateBeaconDuty_EnvelopeBuilderAssignmentFreshness(t *testing.T) { + netCfg := networkconfig.TestNetwork + const epoch = phase0.Epoch(5) + idx := phase0.ValidatorIndex(7) + slot := phase0.Slot(uint64(epoch)*netCfg.SlotsPerEpoch + 3) + + ds := dutystore.New() + assigned := []dutystore.StoreDuty[eth2apiv1.ProposerDuty]{ + {Slot: slot, ValidatorIndex: idx, Duty: ð2apiv1.ProposerDuty{Slot: slot, ValidatorIndex: idx}, InCommittee: true}, + } + ds.Proposer.Set(epoch, assigned) + mv := &messageValidator{netCfg: netCfg, dutyStore: ds} + + indices := []phase0.ValidatorIndex{idx} + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot, indices, false)) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false), ErrNoDuty) + + ds.Proposer.MarkEpochsStale(epoch) + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false)) + ds.Proposer.Set(epoch, assigned) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false), ErrNoDuty) +} diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index cdb79d4d67..16bedd99b0 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -140,7 +140,9 @@ func TestDutyLimit_ProposerPreferences(t *testing.T) { } // A proposer-preferences message must reference a real proposal slot for the validator once the -// slot's epoch is fetched; an unfetched epoch is tolerated (the duty fetch may be in flight). +// slot's epoch is fetched AND fresh; an unfetched epoch is tolerated (the duty fetch may be in +// flight), and so is a stale one (fetched before the latest indices change — rejecting on it would +// permanently starve a just-added validator's one-shot partials). func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) { netCfg := networkconfig.TestNetwork const epoch = phase0.Epoch(5) @@ -148,9 +150,10 @@ func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) slot := phase0.Slot(uint64(epoch)*netCfg.SlotsPerEpoch + 3) ds := dutystore.New() - ds.Proposer.Set(epoch, []dutystore.StoreDuty[eth2apiv1.ProposerDuty]{ + assigned := []dutystore.StoreDuty[eth2apiv1.ProposerDuty]{ {Slot: slot, ValidatorIndex: idx, Duty: ð2apiv1.ProposerDuty{Slot: slot, ValidatorIndex: idx}, InCommittee: true}, - }) + } + ds.Proposer.Set(epoch, assigned) mv := &messageValidator{netCfg: netCfg, dutyStore: ds} indices := []phase0.ValidatorIndex{idx} @@ -161,6 +164,12 @@ func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) // Unfetched epoch → tolerated. unfetched := phase0.Slot(uint64(epoch+10) * netCfg.SlotsPerEpoch) require.NoError(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, unfetched, indices, false)) + // Stale epoch (fetched before the latest indices change) → tolerated like an unfetched one, + // until a refetch restores enforcement. + ds.Proposer.MarkEpochsStale(epoch) + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, slot+1, indices, false)) + ds.Proposer.Set(epoch, assigned) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleProposerPreferences, slot+1, indices, false), ErrNoDuty) } // SignerState tracks distinct ProposerPreferences signing roots (SIP #94 §5): recording is idempotent diff --git a/operator/duties/dutystore/duties.go b/operator/duties/dutystore/duties.go index e888897a9c..1d12688485 100644 --- a/operator/duties/dutystore/duties.go +++ b/operator/duties/dutystore/duties.go @@ -23,11 +23,16 @@ type StoreDuty[D Duty] struct { type Duties[D Duty] struct { mu sync.RWMutex m map[phase0.Epoch]map[phase0.Slot]map[phase0.ValidatorIndex]StoreDuty[D] + // stale flags epochs whose cached duties were fetched before the latest validator-set change. + // The data keeps being served — only freshness-aware checks consult the flag via IsEpochStale — + // and Set (a completed refetch) clears it. + stale map[phase0.Epoch]struct{} } func NewDuties[D Duty]() *Duties[D] { return &Duties[D]{ - m: make(map[phase0.Epoch]map[phase0.Slot]map[phase0.ValidatorIndex]StoreDuty[D]), + m: make(map[phase0.Epoch]map[phase0.Slot]map[phase0.ValidatorIndex]StoreDuty[D]), + stale: make(map[phase0.Epoch]struct{}), } } @@ -111,6 +116,7 @@ func (d *Duties[D]) Set(epoch phase0.Epoch, duties []StoreDuty[D]) { defer d.mu.Unlock() d.m[epoch] = mapped + delete(d.stale, epoch) // a completed fetch is fresh by definition } func (d *Duties[D]) EraseEpochData(epoch phase0.Epoch) { @@ -118,6 +124,7 @@ func (d *Duties[D]) EraseEpochData(epoch phase0.Epoch) { defer d.mu.Unlock() delete(d.m, epoch) + delete(d.stale, epoch) } // EraseBefore drops every cached epoch earlier than the given one, bounding the per-epoch cache. @@ -130,6 +137,11 @@ func (d *Duties[D]) EraseBefore(epoch phase0.Epoch) { delete(d.m, cached) } } + for cached := range d.stale { + if cached < epoch { + delete(d.stale, cached) + } + } } // Clear drops every cached epoch. Used when a refresh must replace the whole cache rather than @@ -139,6 +151,7 @@ func (d *Duties[D]) Clear() { defer d.mu.Unlock() d.m = make(map[phase0.Epoch]map[phase0.Slot]map[phase0.ValidatorIndex]StoreDuty[D]) + d.stale = make(map[phase0.Epoch]struct{}) } func (d *Duties[D]) IsEpochSet(epoch phase0.Epoch) bool { @@ -148,3 +161,26 @@ func (d *Duties[D]) IsEpochSet(epoch phase0.Epoch) bool { _, exists := d.m[epoch] return exists } + +// MarkEpochsStale flags the epochs' cached duties as fetched before the latest validator-set change. +// The data keeps being served (checks that must always enforce assignment still do), but +// freshness-aware duty-existence checks — §5 proposer preferences and the §6 self-build envelope — +// treat a stale epoch like a not-yet-fetched one until a refetch (Set) replaces it: a view predating +// a just-added validator must not permanently reject that validator's honest one-shot messages. +func (d *Duties[D]) MarkEpochsStale(epochs ...phase0.Epoch) { + d.mu.Lock() + defer d.mu.Unlock() + + for _, epoch := range epochs { + d.stale[epoch] = struct{}{} + } +} + +// IsEpochStale reports whether the epoch's cached duties predate the latest validator-set change. +func (d *Duties[D]) IsEpochStale(epoch phase0.Epoch) bool { + d.mu.RLock() + defer d.mu.RUnlock() + + _, stale := d.stale[epoch] + return stale +} diff --git a/operator/duties/dutystore/duties_test.go b/operator/duties/dutystore/duties_test.go index 53354012ce..65a44a75be 100644 --- a/operator/duties/dutystore/duties_test.go +++ b/operator/duties/dutystore/duties_test.go @@ -45,6 +45,36 @@ func TestDutiesSetAndQuery(t *testing.T) { assert.ElementsMatch(t, []phase0.ValidatorIndex{1, 2}, indices) } +// Staleness marks an epoch's cached duties as predating the latest validator-set change: the flag is +// cleared by a refetch (Set) and dropped alongside the data on erasure. +func TestDutiesStaleness(t *testing.T) { + duties := NewDuties[eth2apiv1.ProposerDuty]() + epoch := phase0.Epoch(8) + + require.False(t, duties.IsEpochStale(epoch), "unmarked epochs are not stale") + + duties.Set(epoch, nil) + duties.MarkEpochsStale(epoch, epoch+1) + require.True(t, duties.IsEpochStale(epoch)) + require.True(t, duties.IsEpochStale(epoch+1)) + + duties.Set(epoch, nil) + require.False(t, duties.IsEpochStale(epoch), "a completed refetch freshens the epoch") + require.True(t, duties.IsEpochStale(epoch+1), "other epochs stay stale") + + duties.MarkEpochsStale(epoch) + duties.EraseEpochData(epoch) + require.False(t, duties.IsEpochStale(epoch), "erasing an epoch drops its stale flag") + + duties.MarkEpochsStale(epoch) + duties.EraseBefore(epoch + 1) + require.False(t, duties.IsEpochStale(epoch), "EraseBefore drops stale flags of erased epochs") + require.True(t, duties.IsEpochStale(epoch+1)) + + duties.Clear() + require.False(t, duties.IsEpochStale(epoch+1), "Clear drops all stale flags") +} + func TestDutiesEraseEpochData(t *testing.T) { duties := NewDuties[eth2apiv1.ProposerDuty]() epoch := phase0.Epoch(1) diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index 39fe38c701..b81fd38b64 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -131,6 +131,12 @@ func (h *ProposerHandler) HandleDuties(ctx context.Context) { case <-h.indicesChangeCh: logger.Info("🔁 indices change received") + // Mark the affected epochs' cached duties stale right away: until a refetch replaces + // them, freshness-aware message-validation checks (§5 proposer preferences, §6 envelope) + // treat them like not-yet-fetched epochs, so a view predating the change doesn't reject + // a just-added validator's honest messages (their one-shot broadcasts have no redelivery). + h.duties.MarkEpochsStale(currentEpoch, nextEpoch) + // 1) Declare intents. // Some validator-related state has changed, so re-fetch the duties for the current and next // epoch to keep them up to date for all validators. diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go index dd28c460e1..be57ac0721 100644 --- a/operator/duties/proposer_preferences.go +++ b/operator/duties/proposer_preferences.go @@ -30,8 +30,19 @@ type ProposerPreferencesHandler struct { // gloasForkRechecked is set once the first Gloas tick has forced a lookahead recheck (see // emitForTick). Accessed only from the HandleDuties goroutine. gloasForkRechecked bool + + // emitAfterSlot defers emission until the given slot after a validator-set change (see + // HandleDuties / emitForTick). Accessed only from the HandleDuties goroutine. + emitAfterSlot phase0.Slot } +// indicesChangeEmitGraceSlots is how many slots §5 emission waits after a validator-set change. +// Peers learn of new validators (contract-event sync) and refresh their proposer-duty views on their +// own clocks; a §5 partial broadcast into that window is dropped at their wire with no redelivery +// (an identical re-broadcast is eaten by the gossip seen-cache). Two slots cover typical event-sync +// skew plus the peers' duty-refetch debounce; preferences target future slots, so the delay is free. +const indicesChangeEmitGraceSlots = 2 + func NewProposerPreferencesHandler() *ProposerPreferencesHandler { return &ProposerPreferencesHandler{ emitted: map[phase0.Epoch]phase0.Root{}, @@ -65,9 +76,12 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { case <-h.indicesChangeCh: // New local validators may hold proposal slots in an already-emitted epoch, so drop the - // markers to re-emit the full lookahead for them on the next tick. + // markers to re-emit the full lookahead for them — after a short grace, so the committee's + // peers have learned of the new validators and refreshed their duty views before the + // one-shot partials are broadcast (see indicesChangeEmitGraceSlots). h.logger.Debug("🔀 re-emitting proposer preferences on indices change") clear(h.emitted) + h.emitAfterSlot = h.netCfg.EstimatedCurrentSlot() + indicesChangeEmitGraceSlots case <-h.reorgEventsCh: // A reorg may have changed a proposal slot's dependent_root; recheck the lookahead on the next @@ -80,9 +94,17 @@ func (h *ProposerPreferencesHandler) HandleDuties(ctx context.Context) { // emitForTick emits the lookahead's preferences for the tick's slot: the current epoch (plus the next, // once it's a good time to fetch) in steady state, or the first Gloas epoch when in the pre-fork -// window. Outside both it does nothing (pre-Gloas, no preferences yet). A reorg recheck flagged since -// the last tick is consumed here, forcing the lookahead's dependent roots to be re-evaluated. +// window. Outside both it does nothing (pre-Gloas, no preferences yet). Ticks within the grace after +// a validator-set change are skipped entirely. A reorg recheck flagged since the last tick is +// consumed here, forcing the lookahead's dependent roots to be re-evaluated. func (h *ProposerPreferencesHandler) emitForTick(ctx context.Context, slot phase0.Slot) { + // Within the post-indices-change grace, don't emit (and don't consume a pending recheck): the + // committee is still converging on the new validator set, and partials broadcast now would be + // dropped by peers whose view lags — unrecoverably, as §5 broadcasts are one-shot. + if slot < h.emitAfterSlot { + return + } + recheck := h.recheckLookahead h.recheckLookahead = false diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go index b0fc38f46c..2558781645 100644 --- a/operator/duties/proposer_preferences_test.go +++ b/operator/duties/proposer_preferences_test.go @@ -199,6 +199,39 @@ func TestProposerPreferencesHandler_firstGloasTickRechecksBoundaryEpoch(t *testi require.Len(t, executed, 2, "pre-fork emit and the fork-tick re-emit only") } +// Ticks within the post-indices-change grace neither emit nor consume a pending reorg recheck (or +// the one-time fork recheck); the first post-grace tick proceeds normally. +func TestProposerPreferencesHandler_emitGraceAfterIndicesChange(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + + ctrl := gomock.NewController(t) + vp := NewMockValidatorProvider(ctrl) + + h := NewProposerPreferencesHandler() + h.logger = zap.NewNop() + h.netCfg = netCfg + h.validatorProvider = vp + h.beaconNode = NewMockBeaconNode(ctrl) // no expectations: any fetch during the grace fails the test + h.dutiesExecutor = &captureExecutor{executed: make(chan []*spectypes.ValidatorDuty, 1)} + + slot := phase0.Slot(uint64(gloasEpoch)*netCfg.SlotsPerEpoch) + 3 + h.emitAfterSlot = slot + indicesChangeEmitGraceSlots + h.recheckLookahead = true + + h.emitForTick(context.Background(), slot) // grace: skipped entirely + h.emitForTick(context.Background(), slot+1) + require.True(t, h.recheckLookahead, "a pending recheck must survive the grace") + require.False(t, h.gloasForkRechecked, "the one-time fork recheck must not be consumed during the grace") + + // The first post-grace tick proceeds (and consumes the pending recheck); no local validators, so + // it stops before touching the beacon node. + vp.EXPECT().SelfParticipatingValidators(phase0.Epoch(gloasEpoch)).Return(nil).Times(1) + h.emitForTick(context.Background(), slot+indicesChangeEmitGraceSlots) + require.False(t, h.recheckLookahead, "the pending recheck is consumed by the first post-grace tick") + require.True(t, h.gloasForkRechecked) +} + // evictOutdated drops only epochs strictly before the current one. func TestProposerPreferencesHandler_evictOutdated(t *testing.T) { h := NewProposerPreferencesHandler() From 0ec079d6cf695300b02d8a13a4688db99bf1f298 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 16 Jul 2026 14:05:41 +0300 Subject: [PATCH 114/150] gloas: mark proposer duty views stale on indices-change receipt, not next tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ProposerHandler consumed indicesChangeCh only inside its tick loop, raced against an intra-slot deadline, so the freshness marking §5/§6 message validation relies on could trail the event by up to a slot — eating most of the §5 emission grace. A dedicated top-level case now consumes the event immediately whenever the loop is idle: it marks the current and next epochs stale and declares the refetch intents (the next tick processes intents first thing, before duty execution). A change landing while a tick is being processed is still caught by the tick's own indices-change wait, keeping the same-slot refetch. Eager consumption also relieves the fan-out back-pressure the buffered handler channel was added to mitigate. --- operator/duties/proposer.go | 34 +++++++++++++++++++++-- operator/duties/proposer_test.go | 47 +++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/operator/duties/proposer.go b/operator/duties/proposer.go index b81fd38b64..995f31c8f8 100644 --- a/operator/duties/proposer.go +++ b/operator/duties/proposer.go @@ -64,6 +64,13 @@ func (h *ProposerHandler) WaitShutdown() {} // 3. If necessary, fetch duties for the next epoch. // 4. If necessary, process validator-indices changes by declaring the intents to fetch duties for the epochs // affected by it, also potentially pre-fetching duties so they are ready for processing on the next slot-tick. +// +// On Indices change (received while idle, i.e. between ticks): +// 1. Mark the current/next epochs' cached duty views stale immediately, at event time — freshness-aware +// message validation (§5 proposer preferences, §6 envelope) must start tolerating before any refetch lands. +// 2. Declare the refetch intents; the next tick processes them first thing (before duty execution). +// A change arriving while a tick is being processed is caught by the tick's own indices-change wait +// instead, which additionally refetches in the same slot when early enough. func (h *ProposerHandler) HandleDuties(ctx context.Context) { h.logger.Info("starting duty handler") defer h.logger.Info("duty handler exited") @@ -122,9 +129,11 @@ func (h *ProposerHandler) HandleDuties(ctx context.Context) { h.dutyFetchIntents[nextEpoch] = false } - // 3. Process validator indices changes (if any). We want to process it on the current slot only - // if we are still early into the slot (1 slot-interval is just a guesstimate), otherwise we might - // be delaying the next tick (the duties that need to be executed on the next slot). + // 3. Process validator indices changes that land while this tick is being processed (changes + // arriving between ticks are consumed immediately by the dedicated top-level case). We want to + // process it on the current slot only if we are still early into the slot (1 slot-interval is + // just a guesstimate), otherwise we might be delaying the next tick (the duties that need to be + // executed on the next slot). indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration(currentSlot)) select { @@ -160,6 +169,25 @@ func (h *ProposerHandler) HandleDuties(ctx context.Context) { } }() + case <-h.indicesChangeCh: + // Received while idle (between ticks): mark the cached duty views stale at event time — not + // at the next tick — and declare the refetch intents (processed first thing on the next + // tick, before duty execution). No fetch here: it stays tick-driven. A change arriving + // mid-tick is consumed by the tick's own indices-change wait below, which also refetches + // within the same slot when early enough. + currentSlot := h.netCfg.EstimatedCurrentSlot() + currentEpoch := h.netCfg.EstimatedEpochAtSlot(currentSlot) + nextEpoch := currentEpoch + 1 + + h.logger.Info("🔁 indices change received", + zap.Uint64("current_epoch", uint64(currentEpoch)), + zap.Uint64("current_slot", uint64(currentSlot)), + ) + + h.duties.MarkEpochsStale(currentEpoch, nextEpoch) + h.dutyFetchIntents[currentEpoch] = false + h.dutyFetchIntents[nextEpoch] = false + case reorgEvent := <-h.reorgEventsCh: currentSlot := h.netCfg.EstimatedCurrentSlot() currentEpoch := h.netCfg.EstimatedEpochAtSlot(currentSlot) diff --git a/operator/duties/proposer_test.go b/operator/duties/proposer_test.go index bcecdfe8a3..180e4c385a 100644 --- a/operator/duties/proposer_test.go +++ b/operator/duties/proposer_test.go @@ -10,10 +10,13 @@ import ( eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/phase0" - spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "go.uber.org/zap" + + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/operator/duties/dutystore" "github.com/ssvlabs/ssv/protocol/v2/types" "github.com/ssvlabs/ssv/utils/hashmap" @@ -1585,3 +1588,45 @@ func TestScheduler_Proposer_Fetch_Execute_Next_Epoch_Duty(t *testing.T) { ticker.WaitShutdown() }) } + +// idleTicker never ticks: Next blocks forever, pinning the handler loop to its event cases. +type idleTicker struct{} + +func (idleTicker) Next() <-chan time.Time { return nil } +func (idleTicker) Slot() phase0.Slot { return 0 } + +// An indices change received while the loop is idle (between ticks) marks the current and next +// epochs' cached duty views stale immediately — at event time, not at the next tick — so +// freshness-aware §5/§6 message validation starts tolerating right away. The refetch intents are +// declared for the next tick to process; no fetch happens from the idle path itself. +func TestProposerHandler_IndicesChangeMarksStaleImmediately(t *testing.T) { + netCfg := networkconfig.TestNetwork + store := dutystore.NewDuties[eth2apiv1.ProposerDuty]() + currentEpoch := netCfg.EstimatedCurrentEpoch() + store.Set(currentEpoch, nil) // a pre-change view exists for the current epoch + + h := NewProposerHandler(store, false) + h.logger = zap.NewNop() + h.netCfg = netCfg + h.ticker = idleTicker{} + h.indicesChangeCh = make(chan struct{}, 1) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { defer close(done); h.HandleDuties(ctx) }() + + h.indicesChangeCh <- struct{}{} + + require.Eventually(t, func() bool { + return store.IsEpochStale(currentEpoch) && store.IsEpochStale(currentEpoch+1) + }, time.Second, 5*time.Millisecond, "stale marking must happen on event receipt, without a tick") + + // Stop the loop before reading its unsynchronized intents map. + cancel() + <-done + require.Contains(t, h.dutyFetchIntents, currentEpoch) + require.False(t, h.dutyFetchIntents[currentEpoch], "current-epoch intent must be declared unfulfilled") + require.Contains(t, h.dutyFetchIntents, currentEpoch+1) + require.False(t, h.dutyFetchIntents[currentEpoch+1], "next-epoch intent must be declared unfulfilled") +} From 47afe38d4cec98b4cb22b70f3bf7fecb83213c67 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 16 Jul 2026 14:07:46 +0300 Subject: [PATCH 115/150] =?UTF-8?q?gloas:=20=C2=A75=20skip=20re-broadcast?= =?UTF-8?q?=20of=20an=20unchanged=20in-flight=20preference=20on=20re-emiss?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An indices-change re-emission of a §5 duty that had broadcast its partial but not yet submitted (quorum still converging) re-signed and re-broadcast the identical preference. Peers reject that as a same-peer duplicate by signing root, so the operator self-inflicted a gossip-scoring penalty for a message that was also useless — peers stash the first copy, and the dispatcher replays it into the replacement sub-runner anyway. The slot runner now carries the previously broadcast preference across replacements (alongside the previously submitted one): a re-emission that rebuilds it byte-identically freezes it and keeps converging without re-signing; a dependent_root change still re-signs and broadcasts under the new root. This is the in-flight remainder of issue #2934 — the already-submitted case was covered by the earlier idempotent-re-emission change. --- .../v2/ssv/runner/proposer_preferences.go | 21 +++++++++++++++++++ .../ssv/runner/proposer_preferences_test.go | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 09372556a2..29fd774b1e 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -98,6 +98,7 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za sub := newProposerPreferencesSlotRunner(r.opts) if prev, ok := r.bySlot[slot]; ok { sub.submittedPreferences = prev.submittedPreferences + sub.broadcastPreferences = prev.broadcastPreferences if prev.hasDutyRunning() { prev.markDutyNotRequired() // superseded by the re-emission, not stuck } @@ -299,6 +300,14 @@ type proposerPreferencesSlotRunner struct { // unchanged dependent_root) concludes as not-required instead of duplicating the gossip broadcast // (peers dedup it by signing root) and the beacon-node submit. submittedPreferences *gloas.ProposerPreferences + + // broadcastPreferences is the preference this proposal slot already broadcast a partial signature + // for, carried across sub-runner replacements like submittedPreferences. It covers the in-flight + // case (broadcast, quorum still converging): a re-emission that rebuilds it byte-identically keeps + // converging without re-signing — peers would reject the identical re-broadcast as a same-peer + // duplicate, penalizing this operator's gossip score for nothing (issue #2934); the dispatcher's + // stash replay re-seeds the replacement instead, our own first partial included. + broadcastPreferences *gloas.ProposerPreferences } func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { @@ -440,6 +449,17 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger fields.FeeRecipient(preferences.FeeRecipient[:]), zap.Uint64("target_gas_limit", preferences.TargetGasLimit)) + if r.broadcastPreferences != nil && *preferences == *r.broadcastPreferences { + // A prior incarnation of this slot already broadcast this exact preference (quorum still + // converging): a re-broadcast would be rejected by peers as a same-peer duplicate and only + // self-inflict a gossip-scoring penalty (issue #2934) — and it is useless anyway, since peers + // stash the first copy. Keep the duty running so the stash replay (see StartNewDuty) and live + // partials complete the quorum against the frozen preference above. + logger.Debug("proposer preferences unchanged since last broadcast; skipping re-broadcast", + fields.Slot(proposalSlot)) + return nil + } + msg, err := signBeaconObject(ctx, r, r.NetworkConfig, validatorDuty, preferences, proposalSlot, phase0.DomainType(spectypes.DomainProposerPreferences)) if err != nil { return fmt.Errorf("could not sign proposer preferences: %w", err) @@ -454,6 +474,7 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { return fmt.Errorf("could not sign/broadcast proposer preferences partial sig: %w", err) } + r.broadcastPreferences = preferences return nil } diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index 70db2c2df4..2ffadfc597 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -260,6 +260,13 @@ func TestProposerPreferencesRunner_stashReplayConvergence(t *testing.T) { require.Len(t, network.BroadcastedMsgs, 2, "root change must re-broadcast a fresh partial") require.Len(t, bn.submitted, 1, "stale-root partials must not complete the new quorum") + // A re-emission while the duty is in flight (broadcast, quorum still pending) with an unchanged + // root must not re-broadcast — peers would reject the identical partial as a same-peer duplicate + // (issue #2934) — and the duty must keep converging on the carried-over broadcast state. + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + require.Len(t, network.BroadcastedMsgs, 2, "in-flight re-emission with an unchanged root must not re-broadcast") + require.Len(t, bn.submitted, 1) + // The peers' new-root partials arrive live; quorum re-forms and the updated preference submits. for _, op := range []spectypes.OperatorID{2, 3, 4} { require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerPartial(t, op, bn.dependentRoot))) From a60cf5d342b8d72ed78d039c4b428de7dd32ff9e Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 17 Jul 2026 10:17:12 +0300 Subject: [PATCH 116/150] gloas: log own-validator message drops at the router fall-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message routed to a validator with no running local instance (and no matching committee, non-exporter) was discarded silently. That hop was the one place a §5 proposer-preferences partial could die without any log signature: wire validation already passes on the stored share while the validator instance is still starting (the seconds right after a registration wave), and the one-shot broadcast lost there has no redelivery — the §5 duty starves with nothing to grep. Every other hop already logs its drops (validation ignore/reject with role+slot fields, router buffer-full, queue replay exhaustion). The fall-through now logs at debug — but only for validators that belong to this operator: on shared subnets the same branch routinely swallows other operators' validator traffic, which must stay quiet. The line carries role, message type, pubkey and, for partial-signature messages, slot and signer, making a starved duty attributable in one query. --- operator/validator/controller.go | 29 ++++++++++++ operator/validator/controller_test.go | 67 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 3c5b0c974c..8f0a15190a 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -375,6 +375,8 @@ func (c *Controller) handleRouterMessages() { if !c.messageWorker.TryEnqueue(m) { c.logger.Warn("Failed to enqueue post consensus message: buffer is full") } + } else { + c.logUndeliverableOwnValidatorMessage(m, dutyExecutorID) } default: @@ -384,6 +386,33 @@ func (c *Controller) handleRouterMessages() { } } +// logUndeliverableOwnValidatorMessage makes the silent drop of a message routed to a validator with +// no running local instance diagnosable — but only when the validator is ours: on shared subnets this +// fall-through also swallows other operators' validator traffic, which is routine and must stay +// quiet. The own-validator case is typically a peer's message racing this node's validator startup +// right after registration (the share already passes message validation before the instance starts). +// A one-shot broadcast lost here — e.g. a §5 proposer-preferences partial — has no redelivery, so +// this line is what a starved-duty investigation greps for. +func (c *Controller) logUndeliverableOwnValidatorMessage(msg *queue.SSVMessage, dutyExecutorID []byte) { + if c.validatorStore == nil || c.operatorDataStore == nil { + return + } + share, ok := c.validatorStore.Validator(dutyExecutorID) + if !ok || !share.BelongsToOperator(c.operatorDataStore.GetOperatorID()) { + return + } + + logger := c.logger.With( + fields.RunnerRole(msg.GetID().GetRoleType()), + fields.MessageType(msg.MsgType), + fields.PubKey(dutyExecutorID), + ) + if psm, ok := msg.Body.(*spectypes.PartialSignatureMessages); ok && psm != nil { + logger = logger.With(fields.Slot(psm.Slot), zap.Uint64("signer", ssvtypes.PartialSigMsgSigner(psm))) + } + logger.Debug("dropping message for own validator with no running instance") +} + var nonCommitteeValidatorTTLs = map[spectypes.RunnerRole]int{ spectypes.RoleCommittee: 64, spectypes.RoleAggregatorCommittee: 4, diff --git a/operator/validator/controller_test.go b/operator/validator/controller_test.go index 9e9ad76594..ec7a572d1a 100644 --- a/operator/validator/controller_test.go +++ b/operator/validator/controller_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" "github.com/ssvlabs/ssv/ekmadapter" "github.com/ssvlabs/ssv/ssvsigner/ekm" @@ -45,6 +47,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv/validator" "github.com/ssvlabs/ssv/protocol/v2/types" registrystorage "github.com/ssvlabs/ssv/registry/storage" + storagemocks "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" "github.com/ssvlabs/ssv/storage/basedb" ) @@ -1665,3 +1668,67 @@ func TestSetupCommitteeRunnersProposerF(t *testing.T) { expectedNextRound := qbft.Proposer(state.Height, nextRound, types.OperatorIDsFromOperators(committee), netCfg) require.Equal(t, expectedNextRound, proposerF(state, nextRound)) } + +// A message routed to one of our own validators that has no running local instance is dropped by +// design, but must leave a diagnosable debug line — a one-shot broadcast lost there (e.g. a §5 +// proposer-preferences partial racing validator startup) has no redelivery. Foreign-validator +// messages from shared subnets stay silent. +func TestHandleRouterMessages_LogsOwnValidatorDrop(t *testing.T) { + core, observed := observer.New(zapcore.DebugLevel) + logger := zap.New(core) + const dropSnippet = "dropping message for own validator with no running instance" + + ctrl := gomock.NewController(t) + ownOperatorID := spectypes.OperatorID(1) + + ownPK := bytes.Repeat([]byte{0xaa}, 48) + foreignPK := bytes.Repeat([]byte{0xbb}, 48) + unknownPK := bytes.Repeat([]byte{0xcc}, 48) + + shareFor := func(signer spectypes.OperatorID) *types.SSVShare { + return &types.SSVShare{Share: spectypes.Share{Committee: []*spectypes.ShareMember{{Signer: signer}}}} + } + validatorStore := storagemocks.NewMockValidatorStore(ctrl) + validatorStore.EXPECT().Validator(gomock.Any()).DoAndReturn(func(pk []byte) (*types.SSVShare, bool) { + switch { + case bytes.Equal(pk, ownPK): + return shareFor(ownOperatorID), true // ours, instance not started + case bytes.Equal(pk, foreignPK): + return shareFor(99), true // another operator's validator (shared subnet) + default: + return nil, false + } + }).AnyTimes() + + ctr := setupController(t, logger, MockControllerOptions{ + validatorsMap: validators.New(t.Context()), // empty: no started instances + validatorStore: validatorStore, + operatorDataStore: operatordatastore.New(buildOperatorData(ownOperatorID, "67Ce5c69260bd819B4e0AD13f4b873074D479811")), + validatorCommonOpts: &validator.CommonOptions{}, // exporter disabled: the fall-through branch + }) + go ctr.handleRouterMessages() + + route := func(pk []byte, body any) { + msgID := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, pk, spectypes.RoleProposerPreferences) + ctr.messageRouter.Route(t.Context(), &queue.SSVMessage{ + SSVMessage: &spectypes.SSVMessage{MsgType: spectypes.SSVPartialSignatureMsgType, MsgID: msgID, Data: []byte("data")}, + Body: body, + }) + } + + // Foreign and unknown validators first, own validator last: the router loop is FIFO, so once the + // own-validator line appears, the silent cases have already been processed. + route(foreignPK, nil) + route(unknownPK, nil) + route(ownPK, &spectypes.PartialSignatureMessages{Slot: 42, Messages: []*spectypes.PartialSignatureMessage{{Signer: 2}}}) + + require.Eventually(t, func() bool { + return observed.FilterMessageSnippet(dropSnippet).Len() == 1 + }, time.Second, 5*time.Millisecond, "own-validator drop must be logged") + + entries := observed.FilterMessageSnippet(dropSnippet).All() + require.Len(t, entries, 1, "foreign/unknown validator drops must stay silent") + fieldsByKey := entries[0].ContextMap() + require.EqualValues(t, 42, fieldsByKey["slot"], "partial-sig drops carry the slot") + require.EqualValues(t, 2, fieldsByKey["signer"]) +} From f90b5208ff05cb63b1d175c0b0bae46084c767f4 Mon Sep 17 00:00:00 2001 From: iurii Date: Sun, 26 Jul 2026 14:40:12 +0300 Subject: [PATCH 117/150] gloas: settle the V5/V9/V11 node-side verification items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V5 — §2 aggregate index across BNs. The aggregation path re-derives the attestation-data root only when the cache of our own submitted root misses, and that cached root is the QBFT-decided value, so the decided payload-status index was never at risk on the common path. Make the fallback self-correcting instead: on Gloas a 404 under our beacon node's index retries under the flipped bit, the only other value the §2 index can hold. Both roots come from one fetch, so nothing else can differ. The retry is deliberately not applied on a cache hit, whose root is decided by construction — a 404 there is a real miss and must surface. The re-derivation also stops writing Index through the shared per-slot attestation-data cache pointer; that write-through was dead in practice (Electra+ beacon nodes already return Index=0) but live on the pre-Electra branch. V9 — telemetry gauging. PTC non-convergence now reports as its own no_quorum duty outcome rather than hiding inside the generic "likely stuck" every role shares. §3 has no consensus phase and already marks every other terminal path — abstain to not_required, beacon-node, signing or broadcast failure to failed — so a PTC duty still unmarked at the deadline can only be a convergence miss. The classification keys off the immutable runner role, so it stays race-free against the watcher goroutine. On the §6 side, add ssv.runner.envelope.build_match so reconstruction misses are countable: per operator an "other" share is expected, and the signal is cluster-wide — a decided envelope no operator matched means the builder's bytes were lost. V11 — Web3Signer fork_info on Gloas. No code change needed, but the reasoning is now recorded on GetForkInfo so it need not be re-derived: Web3Signer takes fork.current_version whenever epoch >= fork.epoch and hashes it into a ForkData root without consulting any milestone enum, so an unrecognized Gloas version still yields the correct domain. Its AttestationData schema also has no post-Electra index == 0 check, so the §2 payload-status index survives into the signing root. A live-instance confirm remains. Also unify the package's two 404-detection idioms behind one isNotFound helper covering both the typed go-eth2-client error and the httpStatusError the hand-rolled Gloas endpoints return. --- beacon/goclient/aggregator.go | 89 +++++++++---- beacon/goclient/aggregator_test.go | 119 +++++++++++++++++- beacon/goclient/errors.go | 18 +++ beacon/goclient/proposer_preferences.go | 4 +- observability/attributes.go | 4 + protocol/v2/ssv/runner/envelope.go | 4 +- protocol/v2/ssv/runner/observability.go | 21 ++++ protocol/v2/ssv/runner/runner.go | 19 ++- .../v2/ssv/runner/runner_deadline_test.go | 30 +++++ ssvsigner/ekm/remote_key_manager.go | 10 ++ 10 files changed, 289 insertions(+), 29 deletions(-) diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index 2784c8e21f..5de5d086b7 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -11,8 +11,10 @@ import ( "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" + "go.uber.org/zap" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/observability/log/fields" ) // IsAggregator returns true if the validator is selected as an aggregator for the given @@ -102,18 +104,25 @@ func (gc *GoClient) waitIntoSlot(ctx context.Context, slot phase0.Slot, interval } } -// computeAttestationDataRoot re-derives the attestation data root for the given slot/committee -// from this node's own view, used as a fallback when the cluster-attested root is unknown. +// computeAttestationDataRoot re-derives the attestation-data root for (slot, committeeIndex) from this +// node's own view. On Gloas it also returns the root under the opposite payload-status index — the one +// field of the re-derived data that can disagree with what the cluster decided, which the caller retries +// under (see fetchVersionedAggregate). Both roots come from the same fetch, so they are guaranteed to +// differ in nothing else; a nil altRoot means the slot is pre-Gloas and no such alternative exists. func (gc *GoClient) computeAttestationDataRoot( ctx context.Context, slot phase0.Slot, committeeIndex phase0.CommitteeIndex, -) (root [32]byte, err error) { - attData, _, err := gc.GetAttestationData(ctx, slot) +) (root [32]byte, altRoot *[32]byte, err error) { + cached, _, err := gc.GetAttestationData(ctx, slot) if err != nil { - return root, fmt.Errorf("fetch attestation data: %w", err) + return root, nil, fmt.Errorf("fetch attestation data: %w", err) } + // GetAttestationData hands back the pointer it caches for the slot, shared with every other caller + // (notably the committee runner). Work on a copy so the Index rewrites below can't write through. + attData := *cached + // Explicitly set Index field as beacon nodes may return inconsistent values. // EIP-7549: Electra+ uses Index=0; pre-Electra uses committee index. Gloas (EIP-7732) instead keeps // the BN-supplied payload-status index (0=EMPTY/1=FULL) — it is part of the signed AttestationData @@ -122,10 +131,9 @@ func (gc *GoClient) computeAttestationDataRoot( // beacon node returned (the same source the comment above warns "may return inconsistent values"), // whereas the aggregate is for our duty's slot, which is authoritative. cfg := gc.getBeaconConfig() - switch { - case cfg.IsGloasAtSlot(slot): - // keep attData.Index as the BN returned it - default: + isGloas := cfg.IsGloasAtSlot(slot) + // On Gloas the BN-supplied Index is the payload-status value and is kept exactly as returned. + if !isGloas { version, _ := cfg.ForkAtEpoch(cfg.EstimatedEpochAtSlot(slot)) attData.Index = 0 if version < spec.DataVersionElectra { @@ -135,9 +143,24 @@ func (gc *GoClient) computeAttestationDataRoot( root, err = attData.HashTreeRoot() if err != nil { - return root, fmt.Errorf("fetch attestation data root: %w", err) + return root, nil, fmt.Errorf("fetch attestation data root: %w", err) + } + if !isGloas { + return root, nil, nil + } + + // The §2 payload-status index is a single bit (0=EMPTY / 1=FULL), so flipping it enumerates the + // only value the cluster could have decided other than the one our own beacon node reported. + if attData.Index == 0 { + attData.Index = 1 + } else { + attData.Index = 0 + } + flipped, err := attData.HashTreeRoot() + if err != nil { + return root, nil, fmt.Errorf("hash flipped attestation data root: %w", err) } - return root, nil + return root, &flipped, nil } // fetchVersionedAggregate fetches the aggregate attestation for the given slot/committee, @@ -159,31 +182,55 @@ func (gc *GoClient) fetchVersionedAggregate( committeeIndex phase0.CommitteeIndex, ) (*spec.VersionedAttestation, spec.DataVersion, error) { root, found := gc.attestedDataRoot(slot, committeeIndex) + var altRoot *[32]byte if !found { // No record of our own attestation (it failed or hasn't landed yet) — fall back // to re-deriving the root from this node's view of the slot. var err error - root, err = gc.computeAttestationDataRoot(ctx, slot, committeeIndex) + root, altRoot, err = gc.computeAttestationDataRoot(ctx, slot, committeeIndex) if err != nil { return nil, DataVersionNil, err } } - aggDataReqStart := time.Now() - aggDataResp, err := gc.multiClient.AggregateAttestation(ctx, &api.AggregateAttestationOpts{ - Slot: slot, - AttestationDataRoot: root, - CommitteeIndex: committeeIndex, - }) - recordRequest(ctx, gc.log, "AggregateAttestation", gc.multiClient, http.MethodGet, true, time.Since(aggDataReqStart), err) + resp, err := gc.fetchAggregate(ctx, slot, committeeIndex, root) + if err != nil && altRoot != nil && isNotFound(err) { + // Only the re-derived root can disagree with the cluster: it carries *our* beacon node's + // Gloas payload-status index (SIP #94 §2), while the committee signed the QBFT-decided one. + // A 404 means no aggregate exists under our index, so try the only other value the bit can + // hold rather than silently missing the aggregate. Cheap: one extra GET on a path that has + // already failed, and unreachable from the common cache-hit path, whose root is by + // construction the decided one. + gc.log.Debug("retrying gloas aggregate fetch under the opposite payload-status index", + fields.Slot(slot), + zap.Uint64("committee_index", uint64(committeeIndex))) + resp, err = gc.fetchAggregate(ctx, slot, committeeIndex, *altRoot) + } if err != nil { return nil, DataVersionNil, errMultiClient(fmt.Errorf("fetch aggregate attestation: %w", err), "AggregateAttestation") } - if err := checkPtrResponse(aggDataResp, "aggregate attestation"); err != nil { + if err := checkPtrResponse(resp, "aggregate attestation"); err != nil { return nil, DataVersionNil, errMultiClient(err, "AggregateAttestation") } - return aggDataResp.Data, aggDataResp.Data.Version, nil + return resp.Data, resp.Data.Version, nil +} + +// fetchAggregate performs the aggregate GET for one candidate attestation-data root. +func (gc *GoClient) fetchAggregate( + ctx context.Context, + slot phase0.Slot, + committeeIndex phase0.CommitteeIndex, + root phase0.Root, +) (*api.Response[*spec.VersionedAttestation], error) { + start := time.Now() + resp, err := gc.multiClient.AggregateAttestation(ctx, &api.AggregateAttestationOpts{ + Slot: slot, + AttestationDataRoot: root, + CommitteeIndex: committeeIndex, + }) + recordRequest(ctx, gc.log, "AggregateAttestation", gc.multiClient, http.MethodGet, true, time.Since(start), err) + return resp, err } func versionedAggregateToSSZ(va *spec.VersionedAttestation) (ssz.Marshaler, spec.DataVersion, error) { diff --git a/beacon/goclient/aggregator_test.go b/beacon/goclient/aggregator_test.go index b51e97ed3e..b413055433 100644 --- a/beacon/goclient/aggregator_test.go +++ b/beacon/goclient/aggregator_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/binary" "errors" + "net/http" "sync/atomic" "testing" "testing/synctest" @@ -892,9 +893,125 @@ func TestComputeAttestationDataRoot_GloasKeepsBNIndex(t *testing.T) { require.Equal(t, slot, gotSlot) return attData, nil } - root, err := client.computeAttestationDataRoot(t.Context(), slot, 7) + root, altRoot, err := client.computeAttestationDataRoot(t.Context(), slot, 7) require.NoError(t, err) require.Equal(t, expectedRoot, root) + require.EqualValues(t, 1, attData.Index, "the shared cached attestation data must not be mutated") + + // The alternative root is the same data under the opposite payload-status index (FULL → EMPTY). + require.NotNil(t, altRoot, "a Gloas root carries the payload-status index the caller may retry under") + flipped := *attData + flipped.Index = 0 + require.Equal(t, mustHashTreeRoot(t, &flipped), phase0.Root(*altRoot)) +} + +// Pre-Gloas there is no payload-status bit to retry under, and the Index normalization must not write +// through to the per-slot attestation-data cache the committee runner shares. +func TestComputeAttestationDataRoot_PreGloasHasNoAlternative(t *testing.T) { + t.Parallel() + + cfg := *networkconfig.TestNetwork.Beacon + slot := phase0.Slot(64) + committeeIndex := phase0.CommitteeIndex(3) + + attData := &phase0.AttestationData{ + Slot: slot, + Index: committeeIndex, + Source: &phase0.Checkpoint{Epoch: 1}, + Target: &phase0.Checkpoint{Epoch: 2}, + } + + client := newAggregatorTestClient(&cfg, &aggregatorClientMock{}) + client.fetchAttestationDataFunc = func(context.Context, phase0.Slot) (*phase0.AttestationData, error) { + return attData, nil + } + + _, altRoot, err := client.computeAttestationDataRoot(t.Context(), slot, committeeIndex) + require.NoError(t, err) + require.Nil(t, altRoot) + require.Equal(t, committeeIndex, attData.Index, "the shared cached attestation data must not be mutated") +} + +// On Gloas the re-derived aggregation root carries *our* beacon node's payload-status index, which +// can disagree with the QBFT-decided one the committee signed (SIP #94 §2). A 404 under our index +// must be retried under the only other value the bit can hold rather than silently missing the +// aggregate. +func TestFetchVersionedAggregate_GloasRetriesFlippedPayloadStatus(t *testing.T) { + t.Parallel() + + const gloasEpoch = 6 + cfg := *networkconfig.TestNetworkWithGloas(gloasEpoch).Beacon + slot := cfg.FirstSlotAtEpoch(gloasEpoch) + committeeIndex := phase0.CommitteeIndex(7) + + // The BN reports EMPTY (0); the cluster decided FULL (1), so only the flipped root has an aggregate. + bnData := &phase0.AttestationData{ + Slot: slot, + Index: 0, + Source: &phase0.Checkpoint{Epoch: 1}, + Target: &phase0.Checkpoint{Epoch: 2}, + } + decidedData := *bnData + decidedData.Index = 1 + bnRoot := mustHashTreeRoot(t, bnData) + decidedRoot := mustHashTreeRoot(t, &decidedData) + + aggregate := &spec.VersionedAttestation{Version: spec.DataVersionElectra, Electra: &electra.Attestation{Data: &decidedData}} + + var requested []phase0.Root + service := &aggregatorClientMock{} + service.AggregateAttestationFunc = func(_ context.Context, opts *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) { + requested = append(requested, opts.AttestationDataRoot) + if opts.AttestationDataRoot != decidedRoot { + return nil, &api.Error{StatusCode: http.StatusNotFound} + } + return &api.Response[*spec.VersionedAttestation]{Data: aggregate}, nil + } + + client := newAggregatorTestClient(&cfg, service) + // Gloas attestation data comes from the hand-rolled fetch; inject the BN's view via the hook. + client.fetchAttestationDataFunc = func(context.Context, phase0.Slot) (*phase0.AttestationData, error) { + return bnData, nil + } + + got, _, err := client.fetchVersionedAggregate(t.Context(), slot, committeeIndex) + require.NoError(t, err) + require.Same(t, aggregate, got) + require.Equal(t, []phase0.Root{bnRoot, decidedRoot}, requested, "our index first, then the flip") + + // The retry must not corrupt the shared per-slot attestation-data cache the committee runner reads. + require.EqualValues(t, 0, bnData.Index) +} + +// The flip is scoped to the Gloas re-derivation: a cache hit is by construction the decided root, so +// a 404 there is a genuine miss and must surface instead of provoking a second, meaningless fetch. +func TestFetchVersionedAggregate_NoFlipRetryOnCachedRoot(t *testing.T) { + t.Parallel() + + const gloasEpoch = 6 + cfg := *networkconfig.TestNetworkWithGloas(gloasEpoch).Beacon + slot := cfg.FirstSlotAtEpoch(gloasEpoch) + committeeIndex := phase0.CommitteeIndex(7) + cachedRoot := phase0.Root{0xaa} + + var calls int + service := &aggregatorClientMock{} + service.AggregateAttestationFunc = func(_ context.Context, opts *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) { + calls++ + require.Equal(t, cachedRoot, opts.AttestationDataRoot) + return nil, &api.Error{StatusCode: http.StatusNotFound} + } + + client := newAggregatorTestClient(&cfg, service) + client.attestedDataRootCache.Set(attestedDataRootKey{slot: slot, committee: committeeIndex}, cachedRoot, ttlcache.DefaultTTL) + client.fetchAttestationDataFunc = func(context.Context, phase0.Slot) (*phase0.AttestationData, error) { + t.Fatal("must not re-derive when the submitted root is known") + return nil, nil + } + + _, _, err := client.fetchVersionedAggregate(t.Context(), slot, committeeIndex) + require.Error(t, err) + require.Equal(t, 1, calls) } func aggregatorTestBeaconConfig(genesisTime time.Time) networkconfig.Beacon { diff --git a/beacon/goclient/errors.go b/beacon/goclient/errors.go index 5e9c5a68d4..1e08517b77 100644 --- a/beacon/goclient/errors.go +++ b/beacon/goclient/errors.go @@ -1,7 +1,11 @@ package goclient import ( + "errors" "fmt" + "net/http" + + "github.com/attestantio/go-eth2-client/api" ) // errSingleClient wraps provided error adding more details to it, useful for single-client errors. @@ -13,3 +17,17 @@ func errSingleClient(err error, clientAddr string, routeName string) error { func errMultiClient(err error, routeName string) error { return fmt.Errorf("multi-client request -> %s: %w", routeName, err) } + +// isNotFound reports whether err is a beacon-API 404, over either transport this package speaks: +// go-eth2-client's typed *api.Error, and the *httpStatusError the hand-rolled Gloas endpoints return. +// Callers care about the status, not which client produced it — a 404 means the beacon node has no +// such resource (a missing route, or no aggregate under a given root), as opposed to a transport or +// beacon-node failure worth retrying. +func isNotFound(err error) bool { + var apiErr *api.Error + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusNotFound + } + var statusErr *httpStatusError + return errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound +} diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 3192df042c..7c6548cbde 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -3,7 +3,6 @@ package goclient import ( "context" "encoding/json" - "errors" "fmt" "net/http" @@ -74,8 +73,7 @@ func submitProposerPreferences(ctx context.Context, httpClient *http.Client, add } headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} err = ptcDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) - var statusErr *httpStatusError - if errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound { + if isNotFound(err) { return fmt.Errorf("beacon node lacks the gloas proposer_preferences endpoint (beacon-APIs#608): %w", err) } return err diff --git a/observability/attributes.go b/observability/attributes.go index 98b93aacc4..a1e98bcfee 100644 --- a/observability/attributes.go +++ b/observability/attributes.go @@ -68,6 +68,10 @@ func BuildSourceAttribute(source string) attribute.KeyValue { return attribute.String("ssv.validator.duty.build_source", source) } +func EnvelopeBuildMatchAttribute(match string) attribute.KeyValue { + return attribute.String("ssv.validator.duty.envelope_build_match", match) +} + func BeaconPeriodAttribute(period uint64) attribute.KeyValue { return attribute.KeyValue{ Key: "ssv.beacon.period", diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index fcded41f70..592862bc21 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -207,7 +207,9 @@ func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger // bytes, so content-match publication keeps a non-builder (whose cachedEnvelope is nil) from broadcasting an // empty envelope. func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { - if r.builtDecidedEnvelope(cd.DataSSZ) { + builtIt := r.builtDecidedEnvelope(cd.DataSSZ) + recordEnvelopeBuildMatch(ctx, builtIt) + if builtIt { signed := &gloas.SignedExecutionPayloadEnvelope{Message: r.cachedEnvelope, Signature: sig} if err := r.GetBeaconNode().SubmitExecutionPayloadEnvelope(ctx, signed); err != nil { recordFailedSubmission(ctx, spectypes.BNRoleEnvelopeBuilder) diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 6b0439a4e4..f8e5603c0b 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -130,6 +130,12 @@ var ( observability.InstrumentName(observabilityNamespace, "proposal.build_source"), metric.WithUnit("{proposal}"), metric.WithDescription("submitted Gloas block proposals by build source (self-build vs builder)"))) + + envelopeBuildMatchCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "envelope.build_match"), + metric.WithUnit("{envelope}"), + metric.WithDescription("decided Gloas execution-payload envelopes by whether this operator is the one that built them"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -159,6 +165,21 @@ func recordProposalBuildSource(ctx context.Context, localBuild bool) { proposalBuildSourceCounter.Add(ctx, 1, metric.WithAttributes(observability.BuildSourceAttribute(source))) } +// recordEnvelopeBuildMatch counts a decided §6 envelope by whether this operator's cached envelope +// content-matches it ("self") or not ("other"). Only the matching operator holds the full payload +// bytes and publishes, so per operator an "other" share is expected and benign — the signal is +// cluster-wide: a decided envelope no operator matched is a reconstruction miss (the builder's bytes +// were lost and nobody can publish), which this makes countable instead of inferable only from the +// absence of a publish log. Deliberately independent of whether the subsequent submit succeeded — +// that failure is already counted by ssv.runner.submissions.failed. +func recordEnvelopeBuildMatch(ctx context.Context, self bool) { + match := "other" + if self { + match = "self" + } + envelopeBuildMatchCounter.Add(ctx, 1, metric.WithAttributes(observability.EnvelopeBuildMatchAttribute(match))) +} + func recordPreConsensusDuration(ctx context.Context, duration time.Duration, role spectypes.RunnerRole) { preConsensusDurationHistogram.Record(ctx, duration.Seconds(), metric.WithAttributes( diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index c5709b4a31..248583cb0c 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -310,6 +310,7 @@ const ( dutyOutcomeNotRequired dutyOutcome = "not_required" // completed with nothing to submit (e.g. not selected as aggregator) dutyOutcomeFailed dutyOutcome = "failed" // terminated by a non-recoverable error dutyOutcomeStuck dutyOutcome = "stuck" // not concluded before the end of the current wall-clock slot + dutyOutcomeNoQuorum dutyOutcome = "no_quorum" // reached the deadline having executed, but the signature quorum never formed ) // dutyConclusion is handed by a marker (markDutySucceeded / markDutyNotRequired / markDutyFailed) to @@ -320,8 +321,8 @@ type dutyConclusion struct { } // watchDutyOutcome reports a duty's terminal outcome exactly once: it records the -// ssv.runner.duty.outcome metric and warns for the outcomes worth an operator's attention (failed -// and stuck). It knows nothing about how duties complete — the outcome is delivered by a marker +// ssv.runner.duty.outcome metric and warns for the outcomes worth an operator's attention (failed, +// stuck, no_quorum). It knows nothing about how duties complete — the outcome is delivered by a marker // over dutyConcluded, not by reading runner state — so it's safe alongside the single-threaded // message loop. It MUST be started before executeDuty so a duty that concludes synchronously is still // reported. Each duty gets its own channel: starting the next duty overwrites the field, and the @@ -347,6 +348,16 @@ func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { } } + // A PTC attestation (SIP #94 §3) has no consensus phase, and every other way it can end already + // marks the duty — abstain → not_required, beacon-node/sign/broadcast failure → failed. So + // reaching the deadline unmarked means exactly one thing: the honest-convergence quorum never + // formed. Report that as its own outcome so §3 convergence health is gaugeable, rather than + // hiding inside the generic "likely stuck" that every role shares. + deadlineOutcome := dutyOutcomeStuck + if b.RunnerRoleType == spectypes.RolePTCAttester { + deadlineOutcome = dutyOutcomeNoQuorum + } + report := func(c dutyConclusion) { recordDutyOutcome(ctx, b.GetRole(), c.outcome) switch c.outcome { @@ -354,6 +365,8 @@ func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { logger.Warn("⚠️ duty failed", zap.Error(c.reason)) case dutyOutcomeStuck: logger.Warn("⚠️ duty did not complete before slot end (likely stuck)") + case dutyOutcomeNoQuorum: + logger.Warn("⚠️ duty did not reach signature quorum before slot end (operators did not converge)") case dutyOutcomeSucceeded, dutyOutcomeNotRequired: logger.Debug("duty concluded", zap.String("outcome", string(c.outcome))) } @@ -371,7 +384,7 @@ func (b *BaseRunner) watchDutyOutcome(ctx context.Context, logger *zap.Logger) { case c := <-concluded: report(c) default: - report(dutyConclusion{outcome: dutyOutcomeStuck}) + report(dutyConclusion{outcome: deadlineOutcome}) } } }() diff --git a/protocol/v2/ssv/runner/runner_deadline_test.go b/protocol/v2/ssv/runner/runner_deadline_test.go index 60ecac896d..7116c1911f 100644 --- a/protocol/v2/ssv/runner/runner_deadline_test.go +++ b/protocol/v2/ssv/runner/runner_deadline_test.go @@ -22,6 +22,7 @@ import ( // a duty that concluded successfully. func TestBaseRunner_watchDutyOutcome(t *testing.T) { const deadlineSnippet = "did not complete before slot end" + const noQuorumSnippet = "did not reach signature quorum before slot end" const failedSnippet = "duty failed" // Genesis is set to now so the watcher starts at the beginning of slot 0 and its deadline @@ -67,6 +68,35 @@ func TestBaseRunner_watchDutyOutcome(t *testing.T) { }, time.Second, 5*time.Millisecond, "expected the stuck warning at the proposal slot's start") }) + t.Run("PTC: an unconcluded duty is reported as a quorum miss, not a generic stall", func(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + b := newRunner() + b.RunnerRoleType = spectypes.RolePTCAttester + + b.watchDutyOutcome(context.Background(), zap.New(core)) + + // §3 has no consensus phase and marks every other terminal path, so reaching the deadline + // unmarked can only mean the honest-convergence quorum never formed. + require.Eventually(t, func() bool { + return logs.FilterMessageSnippet(noQuorumSnippet).Len() == 1 + }, time.Second, 5*time.Millisecond, "expected the quorum-miss warning") + require.Zero(t, logs.FilterMessageSnippet(deadlineSnippet).Len(), "PTC must not fall back to the generic stuck warning") + }) + + t.Run("PTC: a concluded duty is reported on its own terms", func(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + b := newRunner() + b.RunnerRoleType = spectypes.RolePTCAttester + + // The deadline reclassification must not leak into duties that did conclude — an abstention + // (markDutyNotRequired) stays silent rather than being counted as a convergence failure. + b.watchDutyOutcome(context.Background(), zap.New(core)) + b.dutyConcluded <- dutyConclusion{outcome: dutyOutcomeNotRequired} + + time.Sleep(100 * time.Millisecond) // well past the slot end + require.Zero(t, logs.Len(), "an abstaining PTC duty must not warn") + }) + t.Run("warns when the duty fails before slot end", func(t *testing.T) { core, logs := observer.New(zapcore.WarnLevel) b := newRunner() diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index d0fcd0d90c..91a3e8713f 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -499,6 +499,16 @@ const GloasDataVersion = spec.DataVersionFulu + 1 // from it, so on a Gloas epoch it must carry the Gloas fork: ForkAtEpoch's version list caps at Fulu // (its TODO(gloas)), so it returns the Fulu fork on Gloas — which would make Web3Signer sign every remote // duty under the wrong (Fulu) domain. Substitute the Gloas fork when it is configured and active. +// +// Sending the Gloas fork suffices even against a Web3Signer with no Gloas support: its domain derivation +// is generic over the version bytes. BeaconStateAccessors.getDomain takes fork.current_version whenever +// epoch >= fork.epoch (always the case here — the substitution above is itself epoch-gated) and hands it +// to MiscHelpers.computeDomain, which only hashes it into a ForkData root; no milestone enum is consulted, +// so an unrecognized Gloas version still yields the correct domain. Its AttestationData schema likewise +// has no post-Electra index == 0 check (unlike go-eth2-client's), so the §2 payload-status index survives +// into the signing root. Established from the Web3Signer/Teku sources rather than a live instance. What +// stays broken on Gloas is unrelated to fork_info: the duties needing request types Web3Signer doesn't +// have (the three new domains) and the Gloas block, which its BLOCK_V2 milestone enum rejects. func (km *RemoteKeyManager) GetForkInfo(epoch phase0.Epoch) web3signer.ForkInfo { _, currentFork := km.beaconConfig.ForkAtEpoch(epoch) if gloasFork, ok := km.beaconConfig.ForkAtVersion(GloasDataVersion); ok && epoch >= gloasFork.Epoch { From e94ae6466fefbe8b8bf2ec785d964515c2fba065 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 28 Jul 2026 21:09:35 +0300 Subject: [PATCH 118/150] ibft/storage: resolve local-directory ssv-spec replaces in GetSpecDir A go.mod replace of ssv-spec with a version-less target is a local directory by go.mod semantics, not a module in the cache; GetSpecDir fed it to the module-cache resolver and failed on a malformed module path. Use the directory directly (resolved against the module root when relative), so the spec-test suites keep running while ssv-spec is replaced with a local checkout during spec development. --- ibft/storage/testutils.go | 40 +++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/ibft/storage/testutils.go b/ibft/storage/testutils.go index e1ef366cf6..cd219c1c95 100644 --- a/ibft/storage/testutils.go +++ b/ibft/storage/testutils.go @@ -247,6 +247,22 @@ func GetSpecDir(path, module string) (string, error) { if replace != nil { modPath = replace.New.Path modVersion = replace.New.Version + if modVersion == "" { + // A version-less replace target is a local directory, not a module in the cache + // (go.mod semantics: a replacement path without a version must be a directory). + dir := modPath + if !filepath.IsAbs(dir) { + root, err := findGoModDir(path) + if err != nil { + return "", err + } + dir = filepath.Join(root, dir) + } + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("local replace directory for %s not found: %w", specModule, err) + } + return filepath.Join(filepath.Clean(dir), module), nil + } } else { // get from require var req *modfile.Require @@ -309,25 +325,33 @@ func GetModulePath(name, version string) (string, error) { return path.Join(cache, escapedPath+"@"+escapedVersion), nil } -func getGoModFile(path string) (*modfile.File, error) { - // The alan_spec build resolves the ssv-spec version from go.spec.alan.mod instead of - // go.mod, so the spec-test vectors come from the alan (pre-Boole) spec release. +// findGoModDir walks up from path to the directory containing the module file. +func findGoModDir(path string) (string, error) { modFileName := specGoModFilename() - - // find project root path for { if _, err := os.Stat(filepath.Join(path, modFileName)); err == nil { - break + return path, nil } path = filepath.Dir(path) if path == "/" { - return nil, fmt.Errorf("could not find %s file", modFileName) + return "", fmt.Errorf("could not find %s file", modFileName) } } +} + +func getGoModFile(path string) (*modfile.File, error) { + // The alan_spec build resolves the ssv-spec version from go.spec.alan.mod instead of + // go.mod, so the spec-test vectors come from the alan (pre-Boole) spec release. + modFileName := specGoModFilename() + + root, err := findGoModDir(path) + if err != nil { + return nil, err + } // read mod file // #nosec G304 -- modFileName is selected by build tags from fixed constants. - buf, err := os.ReadFile(filepath.Join(filepath.Clean(path), modFileName)) + buf, err := os.ReadFile(filepath.Join(filepath.Clean(root), modFileName)) if err != nil { return nil, fmt.Errorf("could not read %s", modFileName) } From 378dd59be38dc952f8050e62bad3ef7d741e8242 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 28 Jul 2026 21:09:44 +0300 Subject: [PATCH 119/150] gloas: re-pin ssv-spec for the request-auth constants; absorb the EnvelopeProposer rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both go.mods move to the ssv-spec#632 branch tip, which adds DomainRequestAuth (builder-specs' DOMAIN_REQUEST_AUTH, 0x0b000001) and RequestAuthPartialSig(9) for issue #2962, and renames the §6 role EnvelopeBuilder to EnvelopeProposer to match SIP-94's wire naming. The rename fallout is mechanical — identifiers, the message-validation test file name, role-referring prose — and envelope.go's error-path domain sentinel joins the prevailing spectypes.DomainError convention while touched. --- go.mod | 2 +- go.sum | 2 + message/validation/common_checks.go | 6 +- message/validation/consensus_validation.go | 6 +- ...lder_test.go => envelope_proposer_test.go} | 36 +++++----- message/validation/partial_validation.go | 2 +- message/validation/signed_ssv_message.go | 2 +- operator/validator/controller.go | 10 +-- protocol/v2/ssv/runner/envelope.go | 70 +++++++++---------- protocol/v2/ssv/runner/envelope_e2e_test.go | 26 +++---- protocol/v2/ssv/runner/envelope_test.go | 32 ++++----- protocol/v2/ssv/value_check_test.go | 2 +- .../gloas/envelope_consensus_data_test.go | 2 +- ssvsigner/go.mod | 2 +- ssvsigner/go.sum | 2 + 15 files changed, 103 insertions(+), 99 deletions(-) rename message/validation/{envelope_builder_test.go => envelope_proposer_test.go} (72%) diff --git a/go.mod b/go.mod index f54461ccdb..bf092c153c 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.8.1 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 github.com/ssvlabs/ssv/ssvsigner v0.0.0-20260415125841-05316f77d5e5 github.com/status-im/keycard-go v0.2.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index bd46d4dc62..bca6c0192e 100644 --- a/go.sum +++ b/go.sum @@ -735,6 +735,8 @@ github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 h1:yGQ4e0VZa3TT github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index d641b3cc72..8b2179670c 100644 --- a/message/validation/common_checks.go +++ b/message/validation/common_checks.go @@ -61,7 +61,7 @@ func (mv *messageValidator) earlySlotAllowance(role spectypes.RunnerRole) time.D func (mv *messageValidator) messageLateness(slot phase0.Slot, role spectypes.RunnerRole, receivedAt time.Time) time.Duration { var ttl uint64 switch role { - case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder, spectypes.RolePTCAttester, ssvtypes.RoleSyncCommitteeContribution: + case spectypes.RoleProposer, spectypes.RoleEnvelopeProposer, spectypes.RolePTCAttester, ssvtypes.RoleSyncCommitteeContribution: ttl = 1 + LateSlotAllowance case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: ttl = mv.maxStoredSlots() @@ -154,7 +154,7 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true - case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: + case spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeProposer: // A validator proposes at most once per slot, so at most SlotsPerEpoch preferences (and likewise // self-build envelopes) per epoch. return mv.netCfg.SlotsPerEpoch, true @@ -214,7 +214,7 @@ func (mv *messageValidator) validateBeaconDuty( // The self-build envelope rides the proposer's slot, so it must carry a real proposer assignment — // guarded like proposer-preferences (fetched and fresh), since the message can arrive before the // epoch's duties are fetched or while a refetch for changed indices is in flight. - if role == spectypes.RoleEnvelopeBuilder { + if role == spectypes.RoleEnvelopeProposer { validatorIndex := indices[0] if mv.dutyStore.Proposer.IsEpochSet(epoch) && !mv.dutyStore.Proposer.IsEpochStale(epoch) && mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil { diff --git a/message/validation/consensus_validation.go b/message/validation/consensus_validation.go index aed850503c..df410946b4 100644 --- a/message/validation/consensus_validation.go +++ b/message/validation/consensus_validation.go @@ -430,7 +430,7 @@ func (mv *messageValidator) maxRound(role spectypes.RunnerRole) (specqbft.Round, switch role { case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: // TODO: check if value for aggregator is correct as there are messages on stage exceeding the limit return 12, nil // TODO: consider calculating based on quick timeout and slow timeout - case spectypes.RoleProposer, spectypes.RoleEnvelopeBuilder: + case spectypes.RoleProposer, spectypes.RoleEnvelopeProposer: return 2, nil case ssvtypes.RoleSyncCommitteeContribution: return 6, nil @@ -537,11 +537,11 @@ func (mv *messageValidator) roundBelongsToAllowedSpread( ) error { role := signedSSVMessage.SSVMessage.GetID().GetRoleType() - // Proposer and envelope-builder round timeouts are relative to QBFT instance start times rather than + // Proposer and envelope-proposer round timeouts are relative to QBFT instance start times rather than // absolute time-into-slot values (until https://github.com/ssvlabs/ssv/issues/2429 is implemented), // since we don't have visibility into the actual QBFT instance state here - we can't check whether the // message round belongs to the allowed spread. - if role == spectypes.RoleProposer || role == spectypes.RoleEnvelopeBuilder { + if role == spectypes.RoleProposer || role == spectypes.RoleEnvelopeProposer { return nil } diff --git a/message/validation/envelope_builder_test.go b/message/validation/envelope_proposer_test.go similarity index 72% rename from message/validation/envelope_builder_test.go rename to message/validation/envelope_proposer_test.go index 0a01b4084c..9b98dbacfa 100644 --- a/message/validation/envelope_builder_test.go +++ b/message/validation/envelope_proposer_test.go @@ -15,15 +15,15 @@ import ( ) // The §6 envelope duty is QBFT with only a post-consensus partial signature (no pre-consensus phase). -func TestPartialSignatureTypeMatchesRole_EnvelopeBuilder(t *testing.T) { +func TestPartialSignatureTypeMatchesRole_EnvelopeProposer(t *testing.T) { mv := &messageValidator{} - require.True(t, mv.partialSignatureTypeMatchesRole(spectypes.PostConsensusPartialSig, spectypes.RoleEnvelopeBuilder)) - require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.RandaoPartialSig, spectypes.RoleEnvelopeBuilder)) - require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.ProposerPreferencesPartialSig, spectypes.RoleEnvelopeBuilder)) + require.True(t, mv.partialSignatureTypeMatchesRole(spectypes.PostConsensusPartialSig, spectypes.RoleEnvelopeProposer)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.RandaoPartialSig, spectypes.RoleEnvelopeProposer)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.ProposerPreferencesPartialSig, spectypes.RoleEnvelopeProposer)) } // The envelope role exists only from the Gloas fork onward. -func TestValidRoleAtSlot_EnvelopeBuilderGloasOnly(t *testing.T) { +func TestValidRoleAtSlot_EnvelopeProposerGloasOnly(t *testing.T) { const gloasEpoch = 100 netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) mv := &messageValidator{netCfg: netCfg} @@ -31,22 +31,22 @@ func TestValidRoleAtSlot_EnvelopeBuilderGloasOnly(t *testing.T) { preGloasSlot := phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch) gloasSlot := phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch) - require.False(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeBuilder, preGloasSlot)) - require.True(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeBuilder, gloasSlot)) + require.False(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeProposer, preGloasSlot)) + require.True(t, mv.validRoleAtSlot(spectypes.RoleEnvelopeProposer, gloasSlot)) } // The envelope is a QBFT role (it has a max round) and shares the proposer's tight bound. -func TestMaxRound_EnvelopeBuilder(t *testing.T) { +func TestMaxRound_EnvelopeProposer(t *testing.T) { mv := &messageValidator{} - round, err := mv.maxRound(spectypes.RoleEnvelopeBuilder) + round, err := mv.maxRound(spectypes.RoleEnvelopeProposer) require.NoError(t, err) require.Equal(t, specqbft.Round(2), round) } // At most one self-build proposal (hence one envelope) per slot → at most SlotsPerEpoch per epoch. -func TestDutyLimit_EnvelopeBuilder(t *testing.T) { +func TestDutyLimit_EnvelopeProposer(t *testing.T) { mv := &messageValidator{netCfg: networkconfig.TestNetwork} - msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeBuilder) + msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeProposer) limit, ok := mv.dutyLimit(msgID, 0, nil) require.True(t, ok) @@ -54,15 +54,15 @@ func TestDutyLimit_EnvelopeBuilder(t *testing.T) { } // The envelope signer advances one slot at a time, so a message for a slot below its max is stale. -func TestMonotonicSlotRole_EnvelopeBuilder(t *testing.T) { +func TestMonotonicSlotRole_EnvelopeProposer(t *testing.T) { mv := &messageValidator{} - require.True(t, mv.monotonicSlotRole(spectypes.RoleEnvelopeBuilder)) + require.True(t, mv.monotonicSlotRole(spectypes.RoleEnvelopeProposer)) } // An envelope message must carry a real proposer assignment once the slot's epoch is fetched AND // fresh; unfetched and stale (mid-indices-change) epochs are tolerated, mirroring the // proposer-preferences rule. -func TestValidateBeaconDuty_EnvelopeBuilderAssignmentFreshness(t *testing.T) { +func TestValidateBeaconDuty_EnvelopeProposerAssignmentFreshness(t *testing.T) { netCfg := networkconfig.TestNetwork const epoch = phase0.Epoch(5) idx := phase0.ValidatorIndex(7) @@ -76,11 +76,11 @@ func TestValidateBeaconDuty_EnvelopeBuilderAssignmentFreshness(t *testing.T) { mv := &messageValidator{netCfg: netCfg, dutyStore: ds} indices := []phase0.ValidatorIndex{idx} - require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot, indices, false)) - require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false), ErrNoDuty) + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeProposer, slot, indices, false)) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeProposer, slot+1, indices, false), ErrNoDuty) ds.Proposer.MarkEpochsStale(epoch) - require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false)) + require.NoError(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeProposer, slot+1, indices, false)) ds.Proposer.Set(epoch, assigned) - require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeBuilder, slot+1, indices, false), ErrNoDuty) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeProposer, slot+1, indices, false), ErrNoDuty) } diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index f52555fb97..56fe9c63fc 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -406,7 +406,7 @@ func (mv *messageValidator) partialSignatureTypeMatchesRole(msgType spectypes.Pa return msgType == spectypes.PostConsensusPartialSig || msgType == ssvtypes.SelectionProofPartialSig case spectypes.RoleProposer: return msgType == spectypes.PostConsensusPartialSig || msgType == spectypes.RandaoPartialSig - case spectypes.RoleEnvelopeBuilder: + case spectypes.RoleEnvelopeProposer: // The §6 envelope duty has no pre-consensus phase, so only post-consensus partial sigs. return msgType == spectypes.PostConsensusPartialSig case ssvtypes.RoleSyncCommitteeContribution: diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index fcf8b3fba6..fdfdc98a74 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -164,7 +164,7 @@ func (mv *messageValidator) validRoleAtSlot(roleType spectypes.RunnerRole, slot return isInBooleFork case ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution: return !isInBooleFork - case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeBuilder: + case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeProposer: return isInGloas default: return false diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 8f0a15190a..93ac4f266e 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -826,7 +826,7 @@ func (c *Controller) onShareInit(share *ssvtypes.SSVShare) (v *validator.Validat // with the block duty); c.ExecuteDuty routes by pubkey back to this validator. startEnvelopeDuty := func(slot phase0.Slot) { go c.ExecuteDuty(validatorCtx, c.logger, &spectypes.ValidatorDuty{ - Type: spectypes.BNRoleEnvelopeBuilder, + Type: spectypes.BNRoleEnvelopeProposer, PubKey: phase0.BLSPubKey(share.ValidatorPubKey), Slot: slot, ValidatorIndex: share.ValidatorIndex, @@ -1212,7 +1212,7 @@ func SetupRunners( runnersType := []spectypes.RunnerRole{ spectypes.RoleProposer, - spectypes.RoleEnvelopeBuilder, + spectypes.RoleEnvelopeProposer, ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution, spectypes.RoleValidatorRegistration, @@ -1274,13 +1274,13 @@ func SetupRunners( ProposedBlockRoots: proposedBlockRoots, StartEnvelopeDuty: startEnvelopeDuty, }) - case spectypes.RoleEnvelopeBuilder: + case spectypes.RoleEnvelopeProposer: // The §6 envelope runner shares the proposer's proposedBlockRoots (it reads the §4 root the // proposer records). Its value-check is built per duty, so none is passed here. The proposer // starts this duty via the StartEnvelopeDuty callback wired in the RoleProposer case above. - runners[role], err = runner.NewEnvelopeBuilderRunner(runner.EnvelopeBuilderRunnerOptions{ + runners[role], err = runner.NewEnvelopeProposerRunner(runner.EnvelopeProposerRunnerOptions{ BaseRunnerOptions: baseOpts, - QBFTController: buildController(spectypes.RoleEnvelopeBuilder), + QBFTController: buildController(spectypes.RoleEnvelopeProposer), ProposedBlockRoots: proposedBlockRoots, HighestDecidedSlot: 0, }) diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 592862bc21..91277b5d94 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -26,14 +26,14 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// EnvelopeBuilderRunner runs the §6 execution-payload-envelope-signing duty (SIP #94 §6, -// RoleEnvelopeBuilder=9). It is a second QBFT instance for the proposer's slot, started by the proposer +// EnvelopeProposerRunner runs the §6 execution-payload-envelope-signing duty (SIP #94 §6, +// RoleEnvelopeProposer=9). It is a second QBFT instance for the proposer's slot, started by the proposer // only on the self-build path (external builders sign their own envelopes). The flow mirrors the proposer // minus pre-consensus: executeDuty produces a BlindedExecutionPayloadEnvelope and runs QBFT over it; // ProcessConsensus signs the decided blinded root under DOMAIN_BEACON_BUILDER and broadcasts a // post-consensus partial signature; ProcessPostConsensus reconstructs the BLS signature and the builder // publishes the full envelope. -type EnvelopeBuilderRunner struct { +type EnvelopeProposerRunner struct { *BaseRunner beacon beacon.BeaconNode @@ -57,8 +57,8 @@ type EnvelopeBuilderRunner struct { cachedEnvelope *gloas.ExecutionPayloadEnvelope } -// EnvelopeBuilderRunnerOptions bundles the dependencies required by NewEnvelopeBuilderRunner. -type EnvelopeBuilderRunnerOptions struct { +// EnvelopeProposerRunnerOptions bundles the dependencies required by NewEnvelopeProposerRunner. +type EnvelopeProposerRunnerOptions struct { BaseRunnerOptions QBFTController *controller.Controller @@ -66,14 +66,14 @@ type EnvelopeBuilderRunnerOptions struct { HighestDecidedSlot phase0.Slot } -func NewEnvelopeBuilderRunner(opts EnvelopeBuilderRunnerOptions) (Runner, error) { +func NewEnvelopeProposerRunner(opts EnvelopeProposerRunnerOptions) (Runner, error) { if len(opts.Share) != 1 { return nil, errors.New("must have one share") } - return &EnvelopeBuilderRunner{ + return &EnvelopeProposerRunner{ BaseRunner: &BaseRunner{ - RunnerRoleType: spectypes.RoleEnvelopeBuilder, + RunnerRoleType: spectypes.RoleEnvelopeProposer, NetworkConfig: opts.NetworkConfig, Share: opts.Share, QBFTController: opts.QBFTController, @@ -89,7 +89,7 @@ func NewEnvelopeBuilderRunner(opts EnvelopeBuilderRunnerOptions) (Runner, error) }, nil } -func (r *EnvelopeBuilderRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { +func (r *EnvelopeProposerRunner) StartNewDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty, quorum uint64) error { validatorDuty, err := validatorDutyFromDuty(duty) if err != nil { return err @@ -98,11 +98,11 @@ func (r *EnvelopeBuilderRunner) StartNewDuty(ctx context.Context, logger *zap.Lo } // ProcessPreConsensus is unreachable: the envelope duty has no pre-consensus phase. -func (r *EnvelopeBuilderRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { - return errors.New("no pre-consensus phase for envelope builder") +func (r *EnvelopeProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return errors.New("no pre-consensus phase for envelope proposer") } -func (r *EnvelopeBuilderRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { +func (r *EnvelopeProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { // Reuse the existing span instead of generating a new one to keep tracing-data lightweight. span := trace.SpanFromContext(ctx) @@ -116,7 +116,7 @@ func (r *EnvelopeBuilderRunner) ProcessConsensus(ctx context.Context, logger *za } r.measurements.EndConsensus() - recordConsensusDuration(ctx, r.measurements.ConsensusTime(), spectypes.RoleEnvelopeBuilder) + recordConsensusDuration(ctx, r.measurements.ConsensusTime(), spectypes.RoleEnvelopeProposer) cd := decidedValue.(*gloas.EnvelopeConsensusData) @@ -153,7 +153,7 @@ func (r *EnvelopeBuilderRunner) ProcessConsensus(ctx context.Context, logger *za return nil } -func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { +func (r *EnvelopeProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { // Reuse the existing span instead of generating a new one to keep tracing-data lightweight. span := trace.SpanFromContext(ctx) @@ -177,7 +177,7 @@ func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger }() r.measurements.EndPostConsensus() - recordPostConsensusDuration(ctx, r.measurements.PostConsensusTime(), spectypes.RoleEnvelopeBuilder) + recordPostConsensusDuration(ctx, r.measurements.PostConsensusTime(), spectypes.RoleEnvelopeProposer) // only 1 root, verified by expectedPostConsensusRootsAndDomain root := roots[0] @@ -206,18 +206,18 @@ func (r *EnvelopeBuilderRunner) ProcessPostConsensus(ctx context.Context, logger // holds and re-submits it — the envelope's decided value is blinded; only its builder holds the full payload // bytes, so content-match publication keeps a non-builder (whose cachedEnvelope is nil) from broadcasting an // empty envelope. -func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { +func (r *EnvelopeProposerRunner) submitEnvelope(ctx context.Context, logger *zap.Logger, cd *gloas.EnvelopeConsensusData, sig phase0.BLSSignature) error { builtIt := r.builtDecidedEnvelope(cd.DataSSZ) recordEnvelopeBuildMatch(ctx, builtIt) if builtIt { signed := &gloas.SignedExecutionPayloadEnvelope{Message: r.cachedEnvelope, Signature: sig} if err := r.GetBeaconNode().SubmitExecutionPayloadEnvelope(ctx, signed); err != nil { - recordFailedSubmission(ctx, spectypes.BNRoleEnvelopeBuilder) + recordFailedSubmission(ctx, spectypes.BNRoleEnvelopeProposer) const errMsg = "could not submit execution payload envelope" logger.Error(errMsg, fields.Slot(cd.Duty.Slot), zap.Error(err)) return fmt.Errorf("%s: %w", errMsg, err) } - recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(cd.Duty.Slot), spectypes.BNRoleEnvelopeBuilder) + recordSuccessfulSubmission(ctx, 1, r.NetworkConfig.EstimatedEpochAtSlot(cd.Duty.Slot), spectypes.BNRoleEnvelopeProposer) logger.Info("✅ published execution payload envelope", fields.Slot(cd.Duty.Slot)) } else { logger.Debug("this operator did not build the decided envelope, skipping publication", fields.Slot(cd.Duty.Slot)) @@ -230,7 +230,7 @@ func (r *EnvelopeBuilderRunner) submitEnvelope(ctx context.Context, logger *zap. // builtDecidedEnvelope reports whether this operator's cached envelope blinds to the decided value — i.e. // it produced the agreed envelope and so holds the full bytes to publish. -func (r *EnvelopeBuilderRunner) builtDecidedEnvelope(decidedDataSSZ []byte) bool { +func (r *EnvelopeProposerRunner) builtDecidedEnvelope(decidedDataSSZ []byte) bool { if r.cachedEnvelope == nil { return false } @@ -245,7 +245,7 @@ func (r *EnvelopeBuilderRunner) builtDecidedEnvelope(decidedDataSSZ []byte) bool return bytes.Equal(blindedSSZ, decidedDataSSZ) } -func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { +func (r *EnvelopeProposerRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { r.measurements.StartDutyFlow() r.cachedEnvelope = nil // drop any envelope cached for a prior duty @@ -281,7 +281,7 @@ func (r *EnvelopeBuilderRunner) executeDuty(ctx context.Context, logger *zap.Log // produceBlindedEnvelope fetches this operator's execution-payload envelope for the slot, caches the full // envelope for the later content-matched publish, and wraps its blinded form as the QBFT value. -func (r *EnvelopeBuilderRunner) produceBlindedEnvelope(ctx context.Context, duty *spectypes.ValidatorDuty, beaconBlockRoot phase0.Root) (*gloas.EnvelopeConsensusData, error) { +func (r *EnvelopeProposerRunner) produceBlindedEnvelope(ctx context.Context, duty *spectypes.ValidatorDuty, beaconBlockRoot phase0.Root) (*gloas.EnvelopeConsensusData, error) { envelope, err := r.GetBeaconNode().GetExecutionPayloadEnvelope(ctx, duty.DutySlot(), beaconBlockRoot) if err != nil { return nil, fmt.Errorf("get execution payload envelope: %w", err) @@ -304,11 +304,11 @@ func (r *EnvelopeBuilderRunner) produceBlindedEnvelope(ctx context.Context, duty } // expectedPreConsensusRootsAndDomain is unreachable: the envelope duty has no pre-consensus phase. -func (r *EnvelopeBuilderRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { - return nil, phase0.DomainType{}, errors.New("no pre-consensus phase for envelope builder") +func (r *EnvelopeProposerRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, spectypes.DomainError, errors.New("no pre-consensus phase for envelope proposer") } -func (r *EnvelopeBuilderRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { +func (r *EnvelopeProposerRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { cd := &gloas.EnvelopeConsensusData{} if err := cd.Decode(r.State.DecidedValue); err != nil { return nil, phase0.DomainType{}, fmt.Errorf("could not decode envelope consensus data: %w", err) @@ -320,34 +320,34 @@ func (r *EnvelopeBuilderRunner) expectedPostConsensusRootsAndDomain(context.Cont return []ssz.HashRoot{blinded}, spectypes.DomainBeaconBuilder, nil } -func (r *EnvelopeBuilderRunner) GetNetwork() protocolp2p.Network { +func (r *EnvelopeProposerRunner) GetNetwork() protocolp2p.Network { return r.network } -func (r *EnvelopeBuilderRunner) GetBeaconNode() beacon.BeaconNode { +func (r *EnvelopeProposerRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } -func (r *EnvelopeBuilderRunner) GetShare() *spectypes.Share { +func (r *EnvelopeProposerRunner) GetShare() *spectypes.Share { for _, share := range r.Share { return share } return nil } -func (r *EnvelopeBuilderRunner) GetSigner() ekm.BeaconSigner { +func (r *EnvelopeProposerRunner) GetSigner() ekm.BeaconSigner { return r.signer } -func (r *EnvelopeBuilderRunner) GetOperatorSigner() ssvtypes.OperatorSigner { +func (r *EnvelopeProposerRunner) GetOperatorSigner() ssvtypes.OperatorSigner { return r.operatorSigner } -func (r *EnvelopeBuilderRunner) MarshalJSON() ([]byte, error) { +func (r *EnvelopeProposerRunner) MarshalJSON() ([]byte, error) { return marshalRunnerStateJSON(r.BaseRunner) } -func (r *EnvelopeBuilderRunner) UnmarshalJSON(data []byte) error { +func (r *EnvelopeProposerRunner) UnmarshalJSON(data []byte) error { br, err := unmarshalRunnerStateJSON(data) if err != nil { return err @@ -357,18 +357,18 @@ func (r *EnvelopeBuilderRunner) UnmarshalJSON(data []byte) error { return nil } -func (r *EnvelopeBuilderRunner) Encode() ([]byte, error) { +func (r *EnvelopeProposerRunner) Encode() ([]byte, error) { return json.Marshal(r) } -func (r *EnvelopeBuilderRunner) Decode(data []byte) error { +func (r *EnvelopeProposerRunner) Decode(data []byte) error { return json.Unmarshal(data, r) } -func (r *EnvelopeBuilderRunner) GetRoot() ([32]byte, error) { +func (r *EnvelopeProposerRunner) GetRoot() ([32]byte, error) { marshaledRoot, err := r.Encode() if err != nil { - return [32]byte{}, fmt.Errorf("could not encode EnvelopeBuilderRunner: %w", err) + return [32]byte{}, fmt.Errorf("could not encode EnvelopeProposerRunner: %w", err) } return sha256.Sum256(marshaledRoot), nil } diff --git a/protocol/v2/ssv/runner/envelope_e2e_test.go b/protocol/v2/ssv/runner/envelope_e2e_test.go index bd663cfd05..e2f74d382e 100644 --- a/protocol/v2/ssv/runner/envelope_e2e_test.go +++ b/protocol/v2/ssv/runner/envelope_e2e_test.go @@ -22,7 +22,7 @@ import ( func envelopeDuty(slot phase0.Slot) *spectypes.ValidatorDuty { return &spectypes.ValidatorDuty{ - Type: spectypes.BNRoleEnvelopeBuilder, + Type: spectypes.BNRoleEnvelopeProposer, PubKey: spectestingutils.TestingValidatorPubKey, Slot: slot, ValidatorIndex: spectestingutils.TestingValidatorIndex, @@ -35,13 +35,13 @@ func newEnvelopeTestBeacon() *envelopeTestBeacon { return &envelopeTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped()} } -func newEnvelopeBuilderRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*EnvelopeBuilderRunner, *spectestingutils.TestKeySet) { +func newEnvelopeProposerRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*EnvelopeProposerRunner, *spectestingutils.TestKeySet) { t.Helper() cfg := cloneTestNetworkConfig() keySet := spectestingutils.Testing4SharesSet() share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleEnvelopeBuilder) + identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleEnvelopeProposer) network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) operator := spectestingutils.TestingCommitteeMember(keySet) @@ -52,7 +52,7 @@ func newEnvelopeBuilderRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*Envel qbftConfig.Network = network controller := protocoltesting.NewTestingQBFTController(keySet, identifier[:], operator, qbftConfig, false) - runnerIface, err := NewEnvelopeBuilderRunner(EnvelopeBuilderRunnerOptions{ + runnerIface, err := NewEnvelopeProposerRunner(EnvelopeProposerRunnerOptions{ BaseRunnerOptions: BaseRunnerOptions{ NetworkConfig: cfg, Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, @@ -66,7 +66,7 @@ func newEnvelopeBuilderRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*Envel }) require.NoError(t, err) - r := runnerIface.(*EnvelopeBuilderRunner) + r := runnerIface.(*EnvelopeProposerRunner) r.SetQBFTRoundTimerF(func(context.Context, *zap.Logger, phase0.Slot) ssv.QBFTRoundTimer { return roundtimer.NewTestingTimer() }) @@ -75,7 +75,7 @@ func newEnvelopeBuilderRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*Envel // setupEnvelopeRunnerForPostConsensus puts the runner in the decided state it would reach after §6 QBFT, // so the post-consensus publish path can be exercised directly (mirrors setupRunnerForPostConsensus). -func setupEnvelopeRunnerForPostConsensus(t *testing.T, runner *EnvelopeBuilderRunner, keySet *spectestingutils.TestKeySet, duty *spectypes.ValidatorDuty, cd *gloas.EnvelopeConsensusData) { +func setupEnvelopeRunnerForPostConsensus(t *testing.T, runner *EnvelopeProposerRunner, keySet *spectestingutils.TestKeySet, duty *spectypes.ValidatorDuty, cd *gloas.EnvelopeConsensusData) { t.Helper() runner.State = NewRunnerState(keySet.Threshold, duty) @@ -113,13 +113,13 @@ func decidedEnvelopeConsensusData(t *testing.T, slot phase0.Slot, envelope *gloa } // The builder — its cached envelope blinds to the decided value — publishes the full signed envelope. -func TestEnvelopeBuilderRunner_SubmitEnvelopeBuilderPublishes(t *testing.T) { +func TestEnvelopeProposerRunner_SubmitEnvelopeProposerPublishes(t *testing.T) { const slot = phase0.Slot(8) envelope := sampleEnvelope() cd := decidedEnvelopeConsensusData(t, slot, envelope) bn := newEnvelopeTestBeacon() - runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + runner, keySet := newEnvelopeProposerRunnerForTest(t, bn) setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) runner.cachedEnvelope = envelope // this operator built the decided envelope @@ -134,12 +134,12 @@ func TestEnvelopeBuilderRunner_SubmitEnvelopeBuilderPublishes(t *testing.T) { // An operator that produced a competing envelope (content mismatch) completes the duty without publishing — // only the builder of the decided envelope holds the matching full bytes. -func TestEnvelopeBuilderRunner_SubmitEnvelopeNonBuilderSkips(t *testing.T) { +func TestEnvelopeProposerRunner_SubmitEnvelopeNonBuilderSkips(t *testing.T) { const slot = phase0.Slot(8) cd := decidedEnvelopeConsensusData(t, slot, sampleEnvelope()) bn := newEnvelopeTestBeacon() - runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + runner, keySet := newEnvelopeProposerRunnerForTest(t, bn) setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) // This operator's cached envelope differs from the decided one (it lost the round), so it does not @@ -159,7 +159,7 @@ func TestEnvelopeBuilderRunner_SubmitEnvelopeNonBuilderSkips(t *testing.T) { // blinded envelope root (signed under DOMAIN_BEACON_BUILDER with the share keys), driving the runner to // reconstruct the envelope signature. Mirrors processPostConsensusQuorum — the envelope role has no spec // message helper, so the partial signatures are built here. -func processEnvelopePostConsensusQuorum(t *testing.T, runner *EnvelopeBuilderRunner, keySet *spectestingutils.TestKeySet, blinded *gloas.BlindedExecutionPayloadEnvelope, slot phase0.Slot) { +func processEnvelopePostConsensusQuorum(t *testing.T, runner *EnvelopeProposerRunner, keySet *spectestingutils.TestKeySet, blinded *gloas.BlindedExecutionPayloadEnvelope, slot phase0.Slot) { t.Helper() signer := spectestingutils.NewTestingKeyManager() @@ -193,13 +193,13 @@ func processEnvelopePostConsensusQuorum(t *testing.T, runner *EnvelopeBuilderRun // ProcessPostConsensus collects a quorum of partial signatures, reconstructs the BLS signature, and (as the // builder) publishes the full signed envelope carrying it. -func TestEnvelopeBuilderRunner_ProcessPostConsensusReconstructsAndPublishes(t *testing.T) { +func TestEnvelopeProposerRunner_ProcessPostConsensusReconstructsAndPublishes(t *testing.T) { const slot = phase0.Slot(8) envelope := sampleEnvelope() cd := decidedEnvelopeConsensusData(t, slot, envelope) bn := newEnvelopeTestBeacon() - runner, keySet := newEnvelopeBuilderRunnerForTest(t, bn) + runner, keySet := newEnvelopeProposerRunnerForTest(t, bn) setupEnvelopeRunnerForPostConsensus(t, runner, keySet, envelopeDuty(slot), cd) runner.cachedEnvelope = envelope // this operator built the decided envelope diff --git a/protocol/v2/ssv/runner/envelope_test.go b/protocol/v2/ssv/runner/envelope_test.go index 0cf84803ed..f1b4d44baa 100644 --- a/protocol/v2/ssv/runner/envelope_test.go +++ b/protocol/v2/ssv/runner/envelope_test.go @@ -28,7 +28,7 @@ func envelopeConsensusDataSSZ(t *testing.T, slot phase0.Slot, blockRoot phase0.R dataSSZ, err := blinded.Encode() require.NoError(t, err) cd := &gloas.EnvelopeConsensusData{ - Duty: spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: slot, ValidatorIndex: 3}, + Duty: spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeProposer, Slot: slot, ValidatorIndex: 3}, DataSSZ: dataSSZ, } encoded, err := cd.Encode() @@ -36,16 +36,16 @@ func envelopeConsensusDataSSZ(t *testing.T, slot phase0.Slot, blockRoot phase0.R return blinded, encoded } -func TestNewEnvelopeBuilderRunner_RequiresOneShare(t *testing.T) { - _, err := NewEnvelopeBuilderRunner(EnvelopeBuilderRunnerOptions{}) +func TestNewEnvelopeProposerRunner_RequiresOneShare(t *testing.T) { + _, err := NewEnvelopeProposerRunner(EnvelopeProposerRunnerOptions{}) require.Error(t, err) } // The post-consensus signing target is the decided blinded envelope's root under DOMAIN_BEACON_BUILDER — // equal to the full envelope's root, so the partial signature is valid for the full envelope. -func TestEnvelopeBuilderRunner_ExpectedPostConsensusRootsAndDomain(t *testing.T) { +func TestEnvelopeProposerRunner_ExpectedPostConsensusRootsAndDomain(t *testing.T) { blinded, encoded := envelopeConsensusDataSSZ(t, 5, phase0.Root{0xaa}) - r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{State: &State{DecidedValue: encoded}}} + r := &EnvelopeProposerRunner{BaseRunner: &BaseRunner{State: &State{DecidedValue: encoded}}} roots, domain, err := r.expectedPostConsensusRootsAndDomain(context.Background()) require.NoError(t, err) @@ -60,18 +60,18 @@ func TestEnvelopeBuilderRunner_ExpectedPostConsensusRootsAndDomain(t *testing.T) } // The envelope duty has no pre-consensus phase; both entry points reject. -func TestEnvelopeBuilderRunner_NoPreConsensus(t *testing.T) { - r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}} +func TestEnvelopeProposerRunner_NoPreConsensus(t *testing.T) { + r := &EnvelopeProposerRunner{BaseRunner: &BaseRunner{}} require.Error(t, r.ProcessPreConsensus(context.Background(), zap.NewNop(), &spectypes.PartialSignatureMessages{})) _, _, err := r.expectedPreConsensusRootsAndDomain() require.Error(t, err) } // executeDuty guards on the proposer having recorded the §4 block root for the slot before producing. -func TestEnvelopeBuilderRunner_ExecuteDutyRequiresDecidedRoot(t *testing.T) { - r := &EnvelopeBuilderRunner{ +func TestEnvelopeProposerRunner_ExecuteDutyRequiresDecidedRoot(t *testing.T) { + r := &EnvelopeProposerRunner{ BaseRunner: &BaseRunner{ - RunnerRoleType: spectypes.RoleEnvelopeBuilder, + RunnerRoleType: spectypes.RoleEnvelopeProposer, Share: map[phase0.ValidatorIndex]*spectypes.Share{ 3: {ValidatorIndex: 3, ValidatorPubKey: spectypes.ValidatorPK{0x42}}, }, @@ -79,7 +79,7 @@ func TestEnvelopeBuilderRunner_ExecuteDutyRequiresDecidedRoot(t *testing.T) { measurements: newMeasurementsStore(), proposedBlockRoots: ssv.NewProposedBlockRoots(), } - duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: 5, ValidatorIndex: 3} + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeProposer, Slot: 5, ValidatorIndex: 3} require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "no decided block root") } @@ -111,10 +111,10 @@ func sampleEnvelope() *gloas.ExecutionPayloadEnvelope { // produceBlindedEnvelope fetches the envelope, caches the full one, and wraps its blinded form (PayloadRoot // = HTR(payload), with the §4 root and builder index preserved) as the QBFT value. -func TestEnvelopeBuilderRunner_ProduceBlindedEnvelope(t *testing.T) { +func TestEnvelopeProposerRunner_ProduceBlindedEnvelope(t *testing.T) { envelope := sampleEnvelope() - r := &EnvelopeBuilderRunner{BaseRunner: &BaseRunner{}, beacon: &envelopeTestBeacon{envelope: envelope}} - duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeBuilder, Slot: 5, ValidatorIndex: 3} + r := &EnvelopeProposerRunner{BaseRunner: &BaseRunner{}, beacon: &envelopeTestBeacon{envelope: envelope}} + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeProposer, Slot: 5, ValidatorIndex: 3} cd, err := r.produceBlindedEnvelope(context.Background(), duty, phase0.Root{0xaa}) require.NoError(t, err) @@ -131,14 +131,14 @@ func TestEnvelopeBuilderRunner_ProduceBlindedEnvelope(t *testing.T) { // builtDecidedEnvelope is the content match: only the operator whose cached envelope blinds to the decided // value holds the full bytes and publishes. -func TestEnvelopeBuilderRunner_BuiltDecidedEnvelope(t *testing.T) { +func TestEnvelopeProposerRunner_BuiltDecidedEnvelope(t *testing.T) { envelope := sampleEnvelope() blinded, err := envelope.Blinded() require.NoError(t, err) decided, err := blinded.Encode() require.NoError(t, err) - r := &EnvelopeBuilderRunner{cachedEnvelope: envelope} + r := &EnvelopeProposerRunner{cachedEnvelope: envelope} require.True(t, r.builtDecidedEnvelope(decided)) // our cached envelope blinds to the decided value require.False(t, r.builtDecidedEnvelope([]byte{0x01})) // a different decided value diff --git a/protocol/v2/ssv/value_check_test.go b/protocol/v2/ssv/value_check_test.go index 43bb9fbd67..05660e174b 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -290,7 +290,7 @@ func encodeEnvelopeValue(t *testing.T, slot phase0.Slot, valIdx phase0.Validator require.NoError(t, err) cd := &gloas.EnvelopeConsensusData{ Duty: spectypes.ValidatorDuty{ - Type: spectypes.BNRoleEnvelopeBuilder, + Type: spectypes.BNRoleEnvelopeProposer, Slot: slot, ValidatorIndex: valIdx, PubKey: pk, diff --git a/protocol/v2/types/gloas/envelope_consensus_data_test.go b/protocol/v2/types/gloas/envelope_consensus_data_test.go index e50bed3d85..01a295301e 100644 --- a/protocol/v2/types/gloas/envelope_consensus_data_test.go +++ b/protocol/v2/types/gloas/envelope_consensus_data_test.go @@ -14,7 +14,7 @@ import ( // mixed cluster. This is what justifies a distinct node-side type instead of reusing the spec one. func TestEnvelopeConsensusDataWireMatchesProposerConsensusData(t *testing.T) { duty := spectypes.ValidatorDuty{ - Type: spectypes.BNRoleEnvelopeBuilder, + Type: spectypes.BNRoleEnvelopeProposer, PubKey: phase0.BLSPubKey{0x01}, Slot: 7, ValidatorIndex: 3, diff --git a/ssvsigner/go.mod b/ssvsigner/go.mod index d3f98aa88a..672ca6a68f 100644 --- a/ssvsigner/go.mod +++ b/ssvsigner/go.mod @@ -30,7 +30,7 @@ require ( github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 github.com/sourcegraph/conc v0.3.0 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/valyala/fasthttp v1.58.0 diff --git a/ssvsigner/go.sum b/ssvsigner/go.sum index 81b0d199ab..71763cf157 100644 --- a/ssvsigner/go.sum +++ b/ssvsigner/go.sum @@ -189,6 +189,8 @@ github.com/ssvlabs/eth2-key-manager v1.5.6 h1:BMxVCsbcIlUiiO0hpePkHxzX0yhKgMkEzV github.com/ssvlabs/eth2-key-manager v1.5.6/go.mod h1:tjzhmMzrc0Lzc/OMW1h9Mz8AhmKH7FQC/nFiMNJ0bd8= github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 51ffe9ce6fa94d3d976051550aa02c0234cb478d Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 28 Jul 2026 21:09:56 +0300 Subject: [PATCH 120/150] gloas: request-auth wire types and the BuilderEntry config vocabulary (#2962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestAuthV1{data ByteList[4096], slot} / SignedRequestAuthV1 per builder-specs — SSZ via sszgen plus the builder-API JSON form — with hash tree roots pinned against an independent merkleization of the builder-specs wire example. BuilderEntry adopts keymanager-APIs#87's field vocabulary for the cluster's direct-builder list: entry cap, at most one default (empty-URL) entry, (URL, AuthData) identity, and auth data defaulting to the UTF-8 bytes of the URL exactly as configured. The shared distinct-root budgets both the §5 dispatcher stash and message validation size from live here too, including SIP-94 §7's normative cap of 4 preference roots. --- protocol/v2/types/gloas/builder_entry.go | 159 +++++++++++++ protocol/v2/types/gloas/builder_entry_test.go | 100 ++++++++ .../v2/types/gloas/proposer_preferences.go | 7 + protocol/v2/types/gloas/request_auth.go | 104 +++++++++ .../v2/types/gloas/request_auth_encoding.go | 214 ++++++++++++++++++ protocol/v2/types/gloas/request_auth_test.go | 143 ++++++++++++ 6 files changed, 727 insertions(+) create mode 100644 protocol/v2/types/gloas/builder_entry.go create mode 100644 protocol/v2/types/gloas/builder_entry_test.go create mode 100644 protocol/v2/types/gloas/request_auth.go create mode 100644 protocol/v2/types/gloas/request_auth_encoding.go create mode 100644 protocol/v2/types/gloas/request_auth_test.go diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go new file mode 100644 index 0000000000..36555313ef --- /dev/null +++ b/protocol/v2/types/gloas/builder_entry.go @@ -0,0 +1,159 @@ +package gloas + +import ( + "encoding/hex" + "fmt" + "net/url" + "strings" +) + +// MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2). It bounds both the +// operator config and, on the wire, the distinct RequestAuthV1 signing roots message validation +// admits per (proposal slot, signer) — the two must stay in step, so both reference this constant. +const MaxBuilderEntries = 8 + +// MaxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one signer may put on +// the wire per proposal slot (issue #2962 B1): one root per authenticatable builder entry, plus +// headroom for a config change between emissions (roots are dependent_root-independent, so unlike +// §5 preferences a reorg re-emission never mints a new one). Message validation enforces it +// world-wide per (slot, signer); the §5 dispatcher sizes its pending stash from it. +const MaxRequestAuthDistinctRoots = MaxBuilderEntries + 4 + +// BuilderIdentity is the identity of a configured builder relationship: the (URL, auth data) pair, +// per keymanager-APIs#87 (multiple entries MAY share a URL with different auth data). It keys the +// per-slot reconstructed-auth cache and the config duplicate check. +func BuilderIdentity(url string, authData []byte) string { + return url + "\x00" + string(authData) +} + +// BuilderEntry is one configured direct builder for the ePBS (Gloas) external-builder overlay +// (issue #2962): the opt-in, off-protocol path that authenticates the cluster to a builder and +// carries per-builder bid preferences. Field vocabulary follows keymanager-APIs#87's BuilderEntry +// (plus beacon-APIs#625's max_trusted_bid), so SSV config reads like the rest of the ecosystem. +// +// Entries MUST be configured identically across ALL operators of every cluster sharing a validator: +// AuthData is threshold-signed into RequestAuthV1 (any byte divergence splits the quorum and +// silently disables that builder), and the unsigned knobs steer bid selection per-operator (their +// divergence is consensus-safe but makes the effective policy "whoever leads the round"). See +// docs/EXTERNAL_BUILDERS.md. +// +// Only URL/AuthData are consumed pre-signing (phase 1); the unsigned knobs take effect with the +// produceBlockV4 POST migration and the semantics track beacon-APIs#625 until it merges. +type BuilderEntry struct { + // URL the beacon node (and, for submitBuilderPreferences, the SSV node) contacts the builder + // on. An empty URL denotes the single default entry: unsigned preferences applied to any + // contacted builder without a matching entry (beacon-APIs#625); it cannot be authenticated. + URL string `yaml:"URL"` + // AuthData is the 0x-hex form of the exact bytes signed into RequestAuthV1.Data — the token + // agreed with the builder out of band. When omitted it defaults to the UTF-8 bytes of URL, + // exactly as configured (the builder-specs default; no canonicalization anywhere). + AuthData string `yaml:"AuthData"` + // BuilderBoostFactor is the percentage multiplier applied to this builder's bid value when the + // beacon node chooses between builder bids and the local payload; nil defaults to the neutral + // 100 (0 forces local, MaxUint64 forces the builder — beacon-APIs#625). + BuilderBoostFactor *uint64 `yaml:"BuilderBoostFactor"` + // MaxTrustedBid caps, in Gwei, how much of this builder's bid value the beacon node may trust + // off-protocol (beacon-APIs#625). + MaxTrustedBid uint64 `yaml:"MaxTrustedBid"` + // MinBid is the minimum bid value in Gwei below which this builder's bids are ignored in favor + // of the local payload (beacon-APIs#625). + MinBid uint64 `yaml:"MinBid"` + // MaxExecutionPayment caps, in Gwei, the execution-layer (trusted, off-protocol) payment + // accepted from this builder; submitted via submitBuilderPreferences and used as the local + // backstop when validating bids (builder-specs). + MaxExecutionPayment uint64 `yaml:"MaxExecutionPayment"` + // PubKey optionally pins the BLS public key bids from this builder must be signed with + // (keymanager-APIs#87), 0x-hex. + PubKey string `yaml:"PubKey"` +} + +// defaultBuilderBoostFactor is the neutral bid multiplier (beacon-APIs#625). +const defaultBuilderBoostFactor = 100 + +// IsDefault reports whether this is the empty-URL default entry (unsigned preferences for any +// contacted builder without a matching entry). +func (e *BuilderEntry) IsDefault() bool { return e.URL == "" } + +// AuthDataBytes returns the exact bytes signed into RequestAuthV1.Data for this builder: the +// decoded AuthData, or the UTF-8 bytes of URL when AuthData is omitted. The default entry yields +// no bytes (it cannot be authenticated). +func (e *BuilderEntry) AuthDataBytes() ([]byte, error) { + if e.AuthData == "" { + return []byte(e.URL), nil + } + b, err := hex.DecodeString(strings.TrimPrefix(e.AuthData, "0x")) + if err != nil { + return nil, fmt.Errorf("invalid AuthData hex: %w", err) + } + if len(b) > MaxRequestAuthDataSize { + return nil, fmt.Errorf("AuthData is %d bytes, exceeding the %d limit", len(b), MaxRequestAuthDataSize) + } + return b, nil +} + +// EffectiveBoostFactor resolves the configured boost factor, defaulting to the neutral 100. +func (e *BuilderEntry) EffectiveBoostFactor() uint64 { + if e.BuilderBoostFactor == nil { + return defaultBuilderBoostFactor + } + return *e.BuilderBoostFactor +} + +// ValidateBuilderEntries checks a configured builder list: entry cap, at most one default +// (empty-URL) entry carrying no AuthData, parseable http(s) URLs, decodable within-limit auth +// data, no duplicate (URL, auth data) identities (the keymanager-APIs#87 entry identity — multiple +// entries MAY share a URL with different auth data), and well-formed optional pubkeys. It cannot +// check the one property that matters most — that every operator of every shared cluster holds the +// identical list — which stays an operational requirement (docs/EXTERNAL_BUILDERS.md). +func ValidateBuilderEntries(entries []BuilderEntry) error { + if len(entries) > MaxBuilderEntries { + return fmt.Errorf("%d builder entries exceed the %d limit", len(entries), MaxBuilderEntries) + } + seen := make(map[string]struct{}, len(entries)) + haveDefault := false + for i := range entries { + e := &entries[i] + if e.IsDefault() { + if haveDefault { + return fmt.Errorf("builder entry %d: at most one default (empty-URL) entry is allowed", i) + } + haveDefault = true + if e.AuthData != "" { + return fmt.Errorf("builder entry %d: the default (empty-URL) entry cannot carry AuthData — there is no single builder to authenticate to", i) + } + } else { + u, err := url.Parse(e.URL) + if err != nil { + return fmt.Errorf("builder entry %d: invalid URL: %w", i, err) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) + } + if len(e.URL) > MaxRequestAuthDataSize { + return fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxRequestAuthDataSize) + } + } + data, err := e.AuthDataBytes() + if err != nil { + return fmt.Errorf("builder entry %d: %w", i, err) + } + if !e.IsDefault() && len(data) == 0 { + return fmt.Errorf("builder entry %d: explicitly empty AuthData — omit the field to default to the URL bytes", i) + } + identity := BuilderIdentity(e.URL, data) + if _, dup := seen[identity]; dup { + return fmt.Errorf("builder entry %d: duplicate (URL, AuthData) identity", i) + } + seen[identity] = struct{}{} + if e.PubKey != "" { + pk, err := hex.DecodeString(strings.TrimPrefix(e.PubKey, "0x")) + if err != nil { + return fmt.Errorf("builder entry %d: invalid PubKey hex: %w", i, err) + } + if len(pk) != 48 { + return fmt.Errorf("builder entry %d: PubKey must be 48 bytes, got %d", i, len(pk)) + } + } + } + return nil +} diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go new file mode 100644 index 0000000000..1b4904efe3 --- /dev/null +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -0,0 +1,100 @@ +package gloas + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuilderEntry_AuthDataBytes(t *testing.T) { + // Omitted AuthData defaults to the UTF-8 bytes of the URL, exactly as configured. + e := &BuilderEntry{URL: "https://builder.example.com"} + b, err := e.AuthDataBytes() + require.NoError(t, err) + require.Equal(t, []byte("https://builder.example.com"), b) + + // Explicit AuthData decodes as 0x-hex. + e = &BuilderEntry{URL: "https://builder.example.com", AuthData: "0x1234567890abcdef"} + b, err = e.AuthDataBytes() + require.NoError(t, err) + require.Equal(t, []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, b) + + // The default entry yields no bytes. + b, err = (&BuilderEntry{}).AuthDataBytes() + require.NoError(t, err) + require.Empty(t, b) + + _, err = (&BuilderEntry{URL: "https://x.example", AuthData: "0xzz"}).AuthDataBytes() + require.ErrorContains(t, err, "invalid AuthData hex") + + _, err = (&BuilderEntry{URL: "https://x.example", AuthData: "0x" + strings.Repeat("00", MaxRequestAuthDataSize+1)}).AuthDataBytes() + require.ErrorContains(t, err, "exceeding") +} + +func TestBuilderEntry_EffectiveBoostFactor(t *testing.T) { + require.Equal(t, uint64(100), (&BuilderEntry{}).EffectiveBoostFactor()) + zero := uint64(0) + require.Equal(t, uint64(0), (&BuilderEntry{BuilderBoostFactor: &zero}).EffectiveBoostFactor()) +} + +func TestValidateBuilderEntries(t *testing.T) { + valid := []BuilderEntry{ + {URL: "https://builder-a.example.com"}, + {URL: "https://builder-b.example.com", AuthData: "0x0102"}, + // Same URL, different auth data — a distinct identity per keymanager-APIs#87. + {URL: "https://builder-b.example.com", AuthData: "0x0304"}, + {}, // the default entry + } + require.NoError(t, ValidateBuilderEntries(valid)) + require.NoError(t, ValidateBuilderEntries(nil)) + + require.ErrorContains(t, + ValidateBuilderEntries(make([]BuilderEntry, MaxBuilderEntries+1)), + "exceed") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{}, {}}), + "at most one default") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{AuthData: "0x01"}}), + "cannot carry AuthData") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "ftp://builder.example.com"}}), + "must be http(s)") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "https://"}}), + "must be http(s)") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", AuthData: "0x"}}), + "explicitly empty AuthData") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{ + {URL: "https://x.example"}, + {URL: "https://x.example"}, + }), + "duplicate") + // Same identity via explicit auth data equal to another entry's URL-derived default. + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{ + {URL: "https://x.example"}, + {URL: "https://x.example", AuthData: "0x" + hexOf("https://x.example")}, + }), + "duplicate") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x01"}}), + "PubKey must be 48 bytes") + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0xzz"}}), + "invalid PubKey hex") + require.NoError(t, + ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x" + strings.Repeat("ab", 48)}})) +} + +func hexOf(s string) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, len(s)*2) + for i := 0; i < len(s); i++ { + out = append(out, digits[s[i]>>4], digits[s[i]&0x0f]) + } + return string(out) +} diff --git a/protocol/v2/types/gloas/proposer_preferences.go b/protocol/v2/types/gloas/proposer_preferences.go index 31b50f0e1b..d0780a5dbb 100644 --- a/protocol/v2/types/gloas/proposer_preferences.go +++ b/protocol/v2/types/gloas/proposer_preferences.go @@ -14,6 +14,13 @@ import ( // graph (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. //go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./proposer_preferences.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix --objs ProposerPreferences,SignedProposerPreferences" +// MaxProposerPreferencesDistinctRoots bounds the distinct ProposerPreferences signing roots one +// signer may put on the wire per proposal slot — SIP #94 §7's normative cap of 4: the extra roots +// come from preference-input changes between emissions (notably a dependent_root shift under +// reorg), and the cap is policy headroom. Message validation enforces it world-wide per +// (slot, signer); the §5 dispatcher sizes its pending stash from it. +const MaxProposerPreferencesDistinctRoots = 4 + // ProposerPreferences is the Gloas (ePBS) preference a proposer broadcasts for an upcoming // proposal slot (SIP #94 §5): the fee recipient and target gas limit builders must honor, pinned to // the proposer-lookahead seed via DependentRoot. Signed under DomainProposerPreferences with domain diff --git a/protocol/v2/types/gloas/request_auth.go b/protocol/v2/types/gloas/request_auth.go new file mode 100644 index 0000000000..7cb7768d7c --- /dev/null +++ b/protocol/v2/types/gloas/request_auth.go @@ -0,0 +1,104 @@ +package gloas + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// Regenerate with `go generate ./...`. The phase0 --include is resolved from the module graph +// (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./request_auth.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs RequestAuthV1,SignedRequestAuthV1" + +// MaxRequestAuthDataSize is builder-specs' MAX_DATA_SIZE: the ByteList limit of RequestAuthV1.Data. +const MaxRequestAuthDataSize = 4096 + +// RequestAuthV1 is builder-specs' request-authentication message: Data is the opaque per-builder +// token agreed out of band (defaulting to the UTF-8 bytes of the builder's advertised URL, exactly +// as advertised — never canonicalized, signed exactly as serialized), and Slot is the proposal slot +// the request is authorized for, not the slot at which it is signed or sent. One signed auth covers +// both builder channels (getExecutionPayloadBid and submitBuilderPreferences). Signed under +// DomainRequestAuth — genesis-style compute_domain, never fork-versioned. Variable-size SSZ. +type RequestAuthV1 struct { + Data []byte `ssz-max:"4096"` + Slot phase0.Slot +} + +// SignedRequestAuthV1 is a RequestAuthV1 plus the validator's signature, carried in builder-API +// request bodies (and forwarded byte-for-byte unchanged by every hop). Variable-size SSZ. +type SignedRequestAuthV1 struct { + Message *RequestAuthV1 + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// requestAuthJSON is the builder-API JSON form: uint64 as a decimal string, data as 0x-hex, per +// go-eth2-client conventions. +type requestAuthJSON struct { + Data string `json:"data"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (r *RequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&requestAuthJSON{ + Data: fmt.Sprintf("%#x", r.Data), + Slot: fmt.Sprintf("%d", r.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { + var data requestAuthJSON + if err := json.Unmarshal(input, &data); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(data.Data, "0x")) + if err != nil { + return fmt.Errorf("invalid value for data: %w", err) + } + if len(b) > MaxRequestAuthDataSize { + return fmt.Errorf("incorrect length for data: %d bytes exceeds the %d limit", len(b), MaxRequestAuthDataSize) + } + r.Data = b + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for slot: %w", err) + } + r.Slot = phase0.Slot(slot) + return nil +} + +// signedRequestAuthJSON is the builder-API JSON form of SignedRequestAuthV1. +type signedRequestAuthJSON struct { + Message *RequestAuthV1 `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedRequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedRequestAuthJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedRequestAuthV1) UnmarshalJSON(input []byte) error { + var data signedRequestAuthJSON + if err := json.Unmarshal(input, &data); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + if err := decodeHexInto(s.Signature[:], data.Signature, "signature"); err != nil { + return err + } + return nil +} diff --git a/protocol/v2/types/gloas/request_auth_encoding.go b/protocol/v2/types/gloas/request_auth_encoding.go new file mode 100644 index 0000000000..072455420c --- /dev/null +++ b/protocol/v2/types/gloas/request_auth_encoding.go @@ -0,0 +1,214 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: cf0262dee516fbc11a6077fbce72135c7be512898dd3db551f94d2a95852ca29 +// Version: 0.1.3 +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the RequestAuthV1 object +func (r *RequestAuthV1) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(r) +} + +// MarshalSSZTo ssz marshals the RequestAuthV1 object to a target array +func (r *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(12) + + // Offset (0) 'Data' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Slot' + dst = ssz.MarshalUint64(dst, uint64(r.Slot)) + + // Field (0) 'Data' + if size := len(r.Data); size > 4096 { + err = ssz.ErrBytesLengthFn("RequestAuthV1.Data", size, 4096) + return + } + dst = append(dst, r.Data...) + + return +} + +// UnmarshalSSZ ssz unmarshals the RequestAuthV1 object +func (r *RequestAuthV1) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 12 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Data' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 12 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Slot' + r.Slot = phase0.Slot(ssz.UnmarshallUint64(buf[4:12])) + + // Field (0) 'Data' + { + buf = tail[o0:] + if len(buf) > 4096 { + return ssz.ErrBytesLength + } + if cap(r.Data) == 0 { + r.Data = make([]byte, 0, len(buf)) + } + r.Data = append(r.Data, buf...) + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the RequestAuthV1 object +func (r *RequestAuthV1) SizeSSZ() (size int) { + size = 12 + + // Field (0) 'Data' + size += len(r.Data) + + return +} + +// HashTreeRoot ssz hashes the RequestAuthV1 object +func (r *RequestAuthV1) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(r) +} + +// HashTreeRootWith ssz hashes the RequestAuthV1 object with a hasher +func (r *RequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Data' + { + elemIndx := hh.Index() + byteLen := uint64(len(r.Data)) + if byteLen > 4096 { + err = ssz.ErrIncorrectListSize + return + } + hh.Append(r.Data) + hh.MerkleizeWithMixin(elemIndx, byteLen, (4096+31)/32) + } + + // Field (1) 'Slot' + hh.PutUint64(uint64(r.Slot)) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the RequestAuthV1 object +func (r *RequestAuthV1) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(r) +} + +// MarshalSSZ ssz marshals the SignedRequestAuthV1 object +func (s *SignedRequestAuthV1) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// MarshalSSZTo ssz marshals the SignedRequestAuthV1 object to a target array +func (s *SignedRequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + offset := int(100) + + // Offset (0) 'Message' + dst = ssz.WriteOffset(dst, offset) + + // Field (1) 'Signature' + dst = append(dst, s.Signature[:]...) + + // Field (0) 'Message' + if dst, err = s.Message.MarshalSSZTo(dst); err != nil { + return + } + + return +} + +// UnmarshalSSZ ssz unmarshals the SignedRequestAuthV1 object +func (s *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size < 100 { + return ssz.ErrSize + } + + tail := buf + var o0 uint64 + + // Offset (0) 'Message' + if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { + return ssz.ErrOffset + } + + if o0 != 100 { + return ssz.ErrInvalidVariableOffset + } + + // Field (1) 'Signature' + copy(s.Signature[:], buf[4:100]) + + // Field (0) 'Message' + { + buf = tail[o0:] + if s.Message == nil { + s.Message = new(RequestAuthV1) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the SignedRequestAuthV1 object +func (s *SignedRequestAuthV1) SizeSSZ() (size int) { + size = 100 + + // Field (0) 'Message' + if s.Message == nil { + s.Message = new(RequestAuthV1) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedRequestAuthV1 object +func (s *SignedRequestAuthV1) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedRequestAuthV1 object with a hasher +func (s *SignedRequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Message' + if err = s.Message.HashTreeRootWith(hh); err != nil { + return + } + + // Field (1) 'Signature' + hh.PutBytes(s.Signature[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the SignedRequestAuthV1 object +func (s *SignedRequestAuthV1) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(s) +} diff --git a/protocol/v2/types/gloas/request_auth_test.go b/protocol/v2/types/gloas/request_auth_test.go new file mode 100644 index 0000000000..c25b346259 --- /dev/null +++ b/protocol/v2/types/gloas/request_auth_test.go @@ -0,0 +1,143 @@ +package gloas + +import ( + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// builderSpecsSignedRequestAuthJSON is the wire example from builder-specs +// examples/gloas/signed_request_auth.json (the OpenAPI example's value field). +const builderSpecsSignedRequestAuthJSON = `{ + "message": { + "data": "0x1234567890abcdef", + "slot": "1" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" +}` + +func TestRequestAuthV1_SSZ(t *testing.T) { + r := &RequestAuthV1{ + Data: []byte("https://builder.example.com"), + Slot: 42, + } + // 4-byte data offset + 8-byte slot + data tail. + require.Equal(t, 12+len(r.Data), r.SizeSSZ()) + + b, err := r.MarshalSSZ() + require.NoError(t, err) + require.Len(t, b, 12+len(r.Data)) + + var dec RequestAuthV1 + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, r, &dec) + + htr1, err := r.HashTreeRoot() + require.NoError(t, err) + htr2, err := dec.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, htr1, htr2) +} + +func TestRequestAuthV1_SSZ_DataLimit(t *testing.T) { + // At the ByteList limit both directions succeed. + atLimit := &RequestAuthV1{Data: make([]byte, MaxRequestAuthDataSize), Slot: 1} + b, err := atLimit.MarshalSSZ() + require.NoError(t, err) + var dec RequestAuthV1 + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Len(t, dec.Data, MaxRequestAuthDataSize) + + // One byte over: marshal of the oversize object and unmarshal of an oversize tail both fail. + over := &RequestAuthV1{Data: make([]byte, MaxRequestAuthDataSize+1), Slot: 1} + _, err = over.MarshalSSZ() + require.Error(t, err) + oversize := append(b, 0x00) + require.Error(t, dec.UnmarshalSSZ(oversize)) +} + +// TestRequestAuthV1_HashTreeRoot_Golden pins the merkleization against roots computed with an +// independent implementation (plain SHA-256 chunk merkleization of the builder-specs layout: +// ByteList[4096] → 128 chunk leaves + length mix-in, then 2-leaf container). +func TestRequestAuthV1_HashTreeRoot_Golden(t *testing.T) { + auth := &RequestAuthV1{Data: []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, Slot: 1} + htr, err := auth.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, + "0x3d6f2e216a08828a2bc2c9644edacd56fef00f2be824abc49f6d1a28569bd880", + phase0.Root(htr).String()) + + empty := &RequestAuthV1{Slot: 1} + htr, err = empty.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, + "0xa5b4c560790a4fbfd24ad385f1353d605987bbbf53549e314241a3093986e773", + phase0.Root(htr).String()) +} + +func TestSignedRequestAuthV1_SSZ(t *testing.T) { + s := &SignedRequestAuthV1{ + Message: &RequestAuthV1{Data: []byte("token"), Slot: 9}, + Signature: phase0.BLSSignature{0xbb, 0xcc}, + } + // 4-byte message offset + 96-byte signature + message tail. + require.Equal(t, 100+12+len(s.Message.Data), s.SizeSSZ()) + + b, err := s.MarshalSSZ() + require.NoError(t, err) + + var dec SignedRequestAuthV1 + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, s, &dec) +} + +// TestSignedRequestAuthV1_BuilderSpecsExample decodes the builder-specs wire example and pins both +// the field values and the hash tree root (computed with an independent implementation). +func TestSignedRequestAuthV1_BuilderSpecsExample(t *testing.T) { + var s SignedRequestAuthV1 + require.NoError(t, json.Unmarshal([]byte(builderSpecsSignedRequestAuthJSON), &s)) + require.Equal(t, []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, s.Message.Data) + require.Equal(t, phase0.Slot(1), s.Message.Slot) + + htr, err := s.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, + "0xed625cfd05f57d635b2f64d57cdcf3b3a3ba2dfece169c5602b31ab62884683e", + phase0.Root(htr).String()) + + // Re-marshaling reproduces the example byte-for-byte up to JSON formatting. + out, err := json.Marshal(&s) + require.NoError(t, err) + require.JSONEq(t, builderSpecsSignedRequestAuthJSON, string(out)) +} + +func TestRequestAuthV1_JSON(t *testing.T) { + r := &RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 123} + out, err := json.Marshal(r) + require.NoError(t, err) + + var dec RequestAuthV1 + require.NoError(t, json.Unmarshal(out, &dec)) + require.Equal(t, r, &dec) + + // Empty data round-trips as "0x". + var empty RequestAuthV1 + require.NoError(t, json.Unmarshal([]byte(`{"data":"0x","slot":"7"}`), &empty)) + require.Empty(t, empty.Data) + require.Equal(t, phase0.Slot(7), empty.Slot) + + // Oversize data, malformed hex, and malformed slot are rejected. + oversize := make([]byte, MaxRequestAuthDataSize+1) + oversizeJSON, err := json.Marshal(&RequestAuthV1{Data: oversize}) + require.NoError(t, err) + require.ErrorContains(t, json.Unmarshal(oversizeJSON, &dec), "incorrect length for data") + require.ErrorContains(t, json.Unmarshal([]byte(`{"data":"0xzz","slot":"1"}`), &dec), "invalid value for data") + require.ErrorContains(t, json.Unmarshal([]byte(`{"data":"0x00","slot":"x"}`), &dec), "invalid value for slot") +} + +func TestSignedRequestAuthV1_JSON_MessageMissing(t *testing.T) { + var s SignedRequestAuthV1 + require.ErrorContains(t, json.Unmarshal([]byte(`{"signature":"0x00"}`), &s), "message missing") +} From e1ef01b0a470fdfdade53c1dfeacbdcdb695c988 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 28 Jul 2026 21:10:03 +0300 Subject: [PATCH 121/150] gloas: DomainRequestAuth signing arms (#2962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goclient computes it genesis-style alongside DomainApplicationBuilder — the application-namespace domains never derive from a fork-versioned state; the local key manager signs it as a plain SSZ root with the other Gloas domains (nothing of it is in the slashing predicate); the remote key manager reports it unsupported like the rest of the Gloas set — Web3Signer has no request-auth type, so remote-signing operators sit out the direct-builder overlay (bounded: the cluster reconstructs while at most f operators are remote-signing). --- beacon/goclient/signing.go | 9 ++++++--- ssvsigner/ekm/local_key_manager.go | 5 +++-- ssvsigner/ekm/local_key_manager_test.go | 1 + ssvsigner/ekm/remote_key_manager.go | 7 +++++++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/beacon/goclient/signing.go b/beacon/goclient/signing.go index 3f6f6e6680..9b8b80d759 100644 --- a/beacon/goclient/signing.go +++ b/beacon/goclient/signing.go @@ -54,9 +54,12 @@ func (gc *GoClient) DomainData( domain phase0.DomainType, ) (phase0.Domain, error) { switch domain { - case spectypes.DomainApplicationBuilder: - // DomainApplicationBuilder is constructed based on what Ethereum network we are connected - // to (Mainnet, Hoodi, etc.) + case spectypes.DomainApplicationBuilder, spectypes.DomainRequestAuth: + // Application-namespace domains are constructed from the connected network's genesis fork + // version with a zero genesis-validators root (compute_domain with defaults), never from a + // fork-versioned state: DomainApplicationBuilder (pre-Gloas validator registrations) and + // DomainRequestAuth (builder-specs' Gloas DOMAIN_REQUEST_AUTH, the direct-builder request + // auth — 0x0b000001, not the beacon DomainBeaconBuilder 0x0b000000). var appDomain phase0.Domain forkData := phase0.ForkData{ CurrentVersion: gc.getBeaconConfig().GenesisForkVersion, diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index ad034ef47b..f386a18f72 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -286,10 +286,11 @@ func (km *LocalKeyManager) signBeaconObject( return nil, nil, fmt.Errorf("obj type is unknown: %T", obj) } return km.signer.SignRegistration(data, domain, pubKey[:]) - case spectypes.DomainPTCAttester, spectypes.DomainProposerPreferences, spectypes.DomainBeaconBuilder: + case spectypes.DomainPTCAttester, spectypes.DomainProposerPreferences, spectypes.DomainBeaconBuilder, spectypes.DomainRequestAuth: // Gloas (ePBS) domains — a plain BLS signature over the SSZ root, no slashing protection (none is // in the slashing predicate): DomainPTCAttester (PTC payload attestation), DomainProposerPreferences - // (proposer preferences), and DomainBeaconBuilder (§6 blinded execution-payload envelope). + // (proposer preferences), DomainBeaconBuilder (§6 blinded execution-payload envelope), and + // DomainRequestAuth (builder-specs RequestAuthV1, the direct-builder request auth). return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 25e8de0c35..bfd87561ab 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -321,6 +321,7 @@ func TestSignBeaconObject(t *testing.T) { {"DomainBeaconBuilder", spectypes.DomainBeaconBuilder}, {"DomainPTCAttester", spectypes.DomainPTCAttester}, {"DomainProposerPreferences", spectypes.DomainProposerPreferences}, + {"DomainRequestAuth", spectypes.DomainRequestAuth}, } { t.Run(tc.name, func(t *testing.T) { _, sig, err := km.(*LocalKeyManager).SignBeaconObject( diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index 91a3e8713f..1d2319751a 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -421,6 +421,13 @@ func (km *RemoteKeyManager) prepareSignRequest( // but those operators must sign self-build envelopes locally. // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type. return web3signer.SignRequest{}, phase0.Root{}, errors.New("execution payload envelope signing is not supported by the remote signer: Web3Signer has no envelope type, use local signing for self-build envelopes") + case spectypes.DomainRequestAuth: + // The Gloas (ePBS) direct-builder request auth (builder-specs RequestAuthV1, issue #2962) has no + // Web3Signer request type, so a remote-signing operator can't contribute auth partials. Bounded + // (the cluster reconstructs while ≤ f operators are remote-signing), but those operators must + // sign locally for the direct-builder overlay to keep its full fault tolerance. + // TODO(gloas): route request-auth signing through Web3Signer once it adds the type. + return web3signer.SignRequest{}, phase0.Root{}, errors.New("request auth signing is not supported by the remote signer: Web3Signer has no request-auth type, use local signing for the direct-builder overlay") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") } From 6419feeeba26b746ca718ee47dc94bc41de76eed Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 28 Jul 2026 21:10:16 +0300 Subject: [PATCH 122/150] =?UTF-8?q?gloas:=20#2962=20phase=201=20=E2=80=94?= =?UTF-8?q?=20threshold=20request-auth=20rounds=20on=20the=20=C2=A75=20dis?= =?UTF-8?q?patcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For each authenticatable configured builder, the §5 slot sub-runner freezes RequestAuthV1{data, proposal_slot} ahead of the preference logic, broadcasts one single-root RequestAuthPartialSig per builder — once per root across re-emissions, since auth roots don't move with dependent_root — collects partials in a dedicated container with no succeeded-gate (auth collection legitimately outlives the §5 preference submit), and reconstructs each quorum into a per-validator RequestAuthCache shared with the proposer runner for the produceBlockV4 POST attach (beacon-APIs#625, upstream-gated). Sub-quorum degrades silently to the enshrined flow — gossiped bids or self-build — and never blocks the proposal; reconstructions are counted, and the §4 build-source telemetry becomes a typed enum staged for the phase-2 reason split (no bid / economics / auth unavailable). Message validation admits the new type under RoleProposerPreferences with its own distinct-root budget and the §5 two-tier REJECT/IGNORE semantics; every other §5 wire rule (earliness, lateness, slot-advance exemption, assignment check, duty limit, fork gates) is role-scoped and applies unchanged. Config: the Builders YAML list (validated at startup) plumbs through ControllerOptions and CommonOptions — now built from a struct literal instead of the 17-argument constructor — to the runner options; documented in EXTERNAL_BUILDERS.md with the all-operators-identical requirement. Riding along: the remaining error-path domain sentinels join spectypes.DomainError. --- cli/operator/config.go | 6 + cli/operator/node.go | 1 + config/config.example.yaml | 15 ++ docs/EXTERNAL_BUILDERS.md | 45 +++- message/validation/const.go | 11 +- message/validation/partial_validation.go | 47 +++- message/validation/request_auth_test.go | 134 ++++++++++ message/validation/seen_msg_types.go | 7 +- message/validation/signer_state.go | 25 ++ operator/validator/controller.go | 45 ++-- protocol/v2/ssv/request_auth_cache.go | 64 +++++ protocol/v2/ssv/request_auth_cache_test.go | 37 +++ protocol/v2/ssv/runner/observability.go | 40 ++- protocol/v2/ssv/runner/proposer.go | 10 +- .../v2/ssv/runner/proposer_preferences.go | 243 +++++++++++++++-- .../ssv/runner/proposer_preferences_test.go | 2 +- protocol/v2/ssv/runner/ptc_attester.go | 2 +- protocol/v2/ssv/runner/request_auth_test.go | 249 ++++++++++++++++++ .../v2/ssv/runner/validator_registration.go | 2 +- protocol/v2/ssv/validator/opts.go | 51 +--- 20 files changed, 920 insertions(+), 116 deletions(-) create mode 100644 message/validation/request_auth_test.go create mode 100644 protocol/v2/ssv/request_auth_cache.go create mode 100644 protocol/v2/ssv/request_auth_cache_test.go create mode 100644 protocol/v2/ssv/runner/request_auth_test.go diff --git a/cli/operator/config.go b/cli/operator/config.go index 8c5e03e431..cde91f2651 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -15,6 +15,7 @@ import ( p2pv1 "github.com/ssvlabs/ssv/network/p2p" "github.com/ssvlabs/ssv/operator" operatorstorage "github.com/ssvlabs/ssv/operator/storage" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" "github.com/ssvlabs/ssv/storage/basedb" ) @@ -49,6 +50,7 @@ type config struct { ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#getting-started-with-mev-configuration for detailed instructions on how to use it."` AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1s (dangerous, may cause missed block proposals)"` ProposerDelayEPBS time.Duration `yaml:"ProposerDelayEPBS" env:"PROPOSER_DELAY_EPBS" env-description:"Post-ePBS (Gloas) counterpart of ProposerDelay, applied from the Gloas fork on (ProposerDelay applies before it). Hard-capped at 1s with no dangerous override. Default 0 (opt-in)."` + Builders []gloas.BuilderEntry `yaml:"Builders" env-description:"Gloas (ePBS) direct-builder connections (opt-in overlay, YAML only). Entries must be configured identically across all operators of every shared committee; see docs/EXTERNAL_BUILDERS.md"` OperatorPrivateKey string `yaml:"OperatorPrivateKey" env:"OPERATOR_KEY" env-description:"Operator private key for contract event decryption"` MetricsAPIPort int `yaml:"MetricsAPIPort" env:"METRICS_API_PORT" env-description:"Port for metrics API server"` EnableTraces bool `yaml:"EnableTraces" env:"ENABLE_TRACES" env-description:"Enable Open Telemetry traces"` @@ -165,6 +167,10 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { c.ProposerDelayEPBS, maxSafeProposerDelay) } + if err := gloas.ValidateBuilderEntries(c.Builders); err != nil { + return resolved{}, fmt.Errorf("invalid Builders configuration: %w", err) + } + // Resolve the operating mode last so a doubly-misconfigured node still surfaces the signing // or proposer-delay error first. m, err := resolveMode(c.ExporterOptions) diff --git a/cli/operator/node.go b/cli/operator/node.go index 1d72013789..bfb71a20c4 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -442,6 +442,7 @@ func newNode( valOpts.Graffiti = []byte(cfg.Graffiti) valOpts.ProposerDelay = cfg.ProposerDelay valOpts.ProposerDelayEPBS = cfg.ProposerDelayEPBS + valOpts.Builders = cfg.Builders valOpts.ValidatorSyncer = metadataSyncer valOpts.ExporterMode = res.isExporter() valOpts.MessageTraceHandler = messageTraceHandler diff --git a/config/config.example.yaml b/config/config.example.yaml index 2d794657fe..d794314d6b 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -58,6 +58,21 @@ OperatorPrivateKey: # mainnet yet; tune only after validating on a Gloas network. See docs/MEV_CONSIDERATIONS.md. # ProposerDelayEPBS: 0 +# Post-ePBS (Gloas) direct-builder connections — an opt-in overlay on top of the enshrined builder flow +# (which needs no configuration and stays the fallback). Every entry MUST be configured identically across +# ALL operators of every committee sharing a validator: AuthData (defaulting to the UTF-8 bytes of URL, +# exactly as written) is threshold-signed, so any divergence silently disables that builder. At most 8 +# entries; one entry may have an empty URL to set default preferences for any contacted builder. +# Monetary values are Gwei. See docs/EXTERNAL_BUILDERS.md. +# Builders: +# - URL: "https://builder.example.com" +# # AuthData: "0x..." # omit to default to the URL bytes +# # BuilderBoostFactor: 100 # bid multiplier %, 0 = always local, default 100 +# # MaxTrustedBid: 0 # cap on trusted (off-protocol) bid value +# # MinBid: 0 # ignore bids below this value +# # MaxExecutionPayment: 0 # cap on trusted execution-layer payment +# # PubKey: "0x..." # optionally pin the builder's bid-signing key + # This enables monitoring at the specified port, see https://github.com/ssvlabs/ssv/tree/main/monitoring MetricsAPIPort: 15000 diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index b842f357f1..372a021616 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -1,11 +1,44 @@ # Builder proposals -> **ePBS / Gloas (EIP-7732).** This page describes the pre-Gloas external-builder flow — out-of-protocol -> PBS via MEV-Boost/commit-boost and relays. At the Gloas fork, in-protocol (enshrined) PBS supersedes it: -> the proposer publishes a block committing to a builder's *bid* instead of fetching a blinded block from a -> relay, the builder reveals the execution payload separately, and a Payload Timeliness Committee attests to -> its on-time arrival. SSV runs these new duties automatically, with no operator configuration. Gloas is not -> active on Ethereum mainnet yet (devnets only); this page will be revised as ePBS approaches mainnet. +> **ePBS / Gloas (EIP-7732).** The bulk of this page describes the pre-Gloas external-builder flow — +> out-of-protocol PBS via MEV-Boost/commit-boost and relays. At the Gloas fork, in-protocol (enshrined) PBS +> supersedes it: the proposer publishes a block committing to a builder's *bid* instead of fetching a +> blinded block from a relay, the builder reveals the execution payload separately, and a Payload +> Timeliness Committee attests to its on-time arrival. SSV runs these new duties automatically, with no +> operator configuration; the optional [direct-builder overlay](#epbs-direct-builder-overlay-gloas) below +> is the one Gloas surface that takes config. Gloas is not active on Ethereum mainnet yet (devnets only); +> this page will be revised as ePBS approaches mainnet. + +## ePBS direct-builder overlay (Gloas) + +On top of the enshrined flow — gossiped bids from staked builders, with local self-build as the +always-available floor — a cluster MAY additionally maintain **direct builder connections**: authenticated +bid requests and per-builder bid preferences, per the Gloas +[builder-specs](https://github.com/ethereum/builder-specs/blob/master/specs/gloas/validator.md) and +[beacon-APIs#625](https://github.com/ethereum/beacon-APIs/pull/625). This is an **opt-in enhancement, not +on the critical path**: a cluster that never configures it still proposes valid blocks, and the enshrined +path stays the fallback whenever the overlay fails or a builder is unavailable. Design and rollout are +tracked in [issue #2962](https://github.com/ssvlabs/ssv/issues/2962). + +Configuration is the `Builders` list (see `config.example.yaml`), using the ecosystem's +[keymanager-APIs#87](https://github.com/ethereum/keymanager-APIs/pull/87) `BuilderEntry` vocabulary: +`URL`, `AuthData`, `BuilderBoostFactor`, `MaxTrustedBid`, `MinBid`, `MaxExecutionPayment`, optional +`PubKey`. + +**Every operator of every committee sharing a validator MUST configure the identical list — all `n` +operators, not just a quorum.** The builder authenticates the cluster by one BLS signature over +`RequestAuthV1{data, slot}` reconstructed from operator partials, and the partials only combine over +byte-identical `data`: + +- `AuthData` divergence on a builder entry splits the signing quorum and **silently disables that builder** + for the affected proposal slots — proposals still succeed via gossiped bids or self-build, so watch the + build-source metrics rather than proposal failures. +- `AuthData` defaults to the UTF-8 bytes of `URL` exactly as configured — so even trailing-slash or case + differences between operators' `URL` values break the quorum unless an explicit shared `AuthData` is set. +- The unsigned knobs (`BuilderBoostFactor`, `MaxTrustedBid`, `MinBid`, `MaxExecutionPayment`) don't affect + signing, but divergence makes the cluster's effective bid policy depend on which operator leads the + round — keep them identical too. They take effect with the produceBlockV4 POST migration + (beacon-APIs#625, pending upstream). ## How to use diff --git a/message/validation/const.go b/message/validation/const.go index e81f26f9ea..211804c43d 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -2,6 +2,8 @@ package validation import ( "time" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) // To add some encoding overhead for ssz, we use (N + N/encodingOverheadDivisor + 4) for a structure with expected size N @@ -33,7 +35,14 @@ const proposerPreferencesEarlyEpochs = 2 // (slot, signer) may contribute (SIP #94 §5). Unlike other pre-consensus messages (capped at 1), a // proposer re-emits its preference under a new root when the proposal slot's dependent_root changes, so // the bound admits a few genuine reorg-driven refreshes while still capping duplicates and flooding. -const maxProposerPreferencesDistinctRoots = 4 +// The value is shared with the §5 dispatcher's pending stash, hence the central constant. +const maxProposerPreferencesDistinctRoots = gloas.MaxProposerPreferencesDistinctRoots + +// maxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one (slot, signer) +// may contribute (issue #2962): one root per configured direct-builder entry — auth roots don't +// depend on dependent_root, so unlike §5 preferences a reorg never mints new ones — plus headroom +// for a config change between emissions. Shared with the config entry cap and the dispatcher stash. +const maxRequestAuthDistinctRoots = gloas.MaxRequestAuthDistinctRoots const ( signatureSize = 256 diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 56fe9c63fc..4c6606e16d 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -122,7 +122,7 @@ func (mv *messageValidator) validatePartialSignatureMessageSemantics( // - ValidatorRegistrationPartialSig for Validator Registration // - VoluntaryExitPartialSig for Voluntary Exit // - PTCAttesterPartialSig for PTC attestation - // - ProposerPreferencesPartialSig for Proposer Preferences + // - ProposerPreferencesPartialSig or RequestAuthPartialSig for Proposer Preferences if !mv.partialSignatureTypeMatchesRole(partialSignatureMessages.Type, role) { return ErrPartialSignatureTypeRoleMismatch } @@ -200,7 +200,8 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic( // - 1 ValidatorRegistrationPartialSig for Validator Registration // - 1 VoluntaryExitPartialSig for Voluntary Exit // - 1 PTCAttesterPartialSig for PTC attestation - // - 1 ProposerPreferencesPartialSig for Proposer Preferences + // - 1 ProposerPreferencesPartialSig for Proposer Preferences (distinct-root budget), plus + // RequestAuthPartialSig up to its own distinct-root budget (issue #2962) if err := validatePartialSignatureMessageLimit(partialSignatureMessages, receivedFrom, signerState); err != nil { return err } @@ -320,6 +321,25 @@ func validatePartialSignatureMessageLimit( e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) world-wide", signerState.World.proposerPreferencesRootCount()) return e } + case spectypes.RequestAuthPartialSig: + // Issue #2962 (§5 request-auth extension): one root per configured direct builder per + // proposal slot, admitted up to maxRequestAuthDistinctRoots distinct roots per (slot, signer) + // with the same two-tier handling as §5 preferences — a same-peer repeat of a seen root is a + // provable duplicate (REJECT), a relayed repeat or over-budget distinct root is rate-limiting + // (IGNORE). + root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) + if signerState.Peer(receivedFrom).hasRequestAuthRoot(root) { + e := ErrTooManyPartialSigMessage + e.reject = true + e.got = "request-auth, duplicate signing root from peer" + return e + } + if signerState.World.hasRequestAuthRoot(root) || + signerState.World.requestAuthRootCount() >= maxRequestAuthDistinctRoots { + e := ErrTooManyPartialSigMessage + e.got = fmt.Sprintf("request-auth, %d distinct root(s) world-wide", signerState.World.requestAuthRootCount()) + return e + } case spectypes.PostConsensusPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPostConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. @@ -369,13 +389,21 @@ func (mv *messageValidator) updatePartialSignatureState( return err } - // SIP #94 §5: record the distinct signing root so a dependent_root re-emission is admitted up to the - // bound (see validatePartialSignatureMessageLimit). Exactly one signature for this role (validated - // earlier), so Messages[0] holds the root. - if partialSignatureMessages.Type == spectypes.ProposerPreferencesPartialSig { + // SIP #94 §5 (and its issue #2962 request-auth extension): record the distinct signing root so a + // legitimate re-emission — a dependent_root refresh for preferences, another configured builder + // for request auths — is admitted up to its bound (see validatePartialSignatureMessageLimit). + // Exactly one signature for these types (validated earlier), so Messages[0] holds the root. + switch partialSignatureMessages.Type { + case spectypes.ProposerPreferencesPartialSig: root := partialSignatureMessages.Messages[0].SigningRoot signerState.Peer(receivedFrom).recordProposerPreferencesRoot(root) signerState.World.recordProposerPreferencesRoot(root) + case spectypes.RequestAuthPartialSig: + root := partialSignatureMessages.Messages[0].SigningRoot + signerState.Peer(receivedFrom).recordRequestAuthRoot(root) + signerState.World.recordRequestAuthRoot(root) + default: + // Every other type is capped by the SeenMsgTypes bits recorded above, not by root. } return nil @@ -391,7 +419,8 @@ func (mv *messageValidator) validPartialSigMsgType(msgType spectypes.PartialSigM spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig, - spectypes.ProposerPreferencesPartialSig: + spectypes.ProposerPreferencesPartialSig, + spectypes.RequestAuthPartialSig: return true default: return false @@ -420,7 +449,9 @@ func (mv *messageValidator) partialSignatureTypeMatchesRole(msgType spectypes.Pa case spectypes.RolePTCAttester: return msgType == spectypes.PTCAttesterPartialSig case spectypes.RoleProposerPreferences: - return msgType == spectypes.ProposerPreferencesPartialSig + // The role carries both the §5 preference round and the issue #2962 request-auth rounds — + // same duty cadence, distinct signing domains, so distinct partial-sig types. + return msgType == spectypes.ProposerPreferencesPartialSig || msgType == spectypes.RequestAuthPartialSig default: return false } diff --git a/message/validation/request_auth_test.go b/message/validation/request_auth_test.go new file mode 100644 index 0000000000..f0e382d27f --- /dev/null +++ b/message/validation/request_auth_test.go @@ -0,0 +1,134 @@ +package validation + +import ( + "errors" + "testing" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/require" + + specqbft "github.com/ssvlabs/ssv-spec/qbft" + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +// RequestAuth partials ride the RoleProposerPreferences wire (issue #2962): the role admits both +// partial-sig types, and no other role admits RequestAuthPartialSig. +func TestPartialSignatureTypeMatchesRole_RequestAuth(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.partialSignatureTypeMatchesRole(spectypes.RequestAuthPartialSig, spectypes.RoleProposerPreferences)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.RequestAuthPartialSig, spectypes.RoleProposer)) + require.False(t, mv.partialSignatureTypeMatchesRole(spectypes.RequestAuthPartialSig, spectypes.RoleValidatorRegistration)) +} + +func TestValidPartialSigMsgType_RequestAuth(t *testing.T) { + mv := &messageValidator{} + require.True(t, mv.validPartialSigMsgType(spectypes.RequestAuthPartialSig)) +} + +// SignerState tracks distinct RequestAuthV1 signing roots independently of the §5 preference roots: +// recording is idempotent per root, the two sets never bleed into each other's budgets. +func TestSignerState_RequestAuthRoots(t *testing.T) { + s := &SignerState{} + r1 := [32]byte{1} + r2 := [32]byte{2} + + require.Equal(t, 0, s.requestAuthRootCount()) + require.False(t, s.hasRequestAuthRoot(r1)) + + s.recordRequestAuthRoot(r1) + require.True(t, s.hasRequestAuthRoot(r1)) + require.Equal(t, 1, s.requestAuthRootCount()) + + // Recording an already-seen root is a no-op. + s.recordRequestAuthRoot(r1) + require.Equal(t, 1, s.requestAuthRootCount()) + + s.recordRequestAuthRoot(r2) + require.Equal(t, 2, s.requestAuthRootCount()) + + // The two root sets are independent: the same root counts once per type, not globally. + s.recordProposerPreferencesRoot(r1) + require.Equal(t, 1, s.proposerPreferencesRootCount()) + require.Equal(t, 2, s.requestAuthRootCount()) +} + +// RequestAuth pre-consensus admits up to maxRequestAuthDistinctRoots distinct signing roots per +// (slot, signer) — one per configured builder (issue #2962) — with the §5 two-tier handling: only a +// same-peer repeat of a seen root is REJECT'd; a relayed repeat or a distinct root past the cap is +// IGNORE'd. The budget is separate from the §5 preference budget. +func TestValidatePartialSignatureMessageLimit_RequestAuth(t *testing.T) { + raMsg := func(root [32]byte) *spectypes.PartialSignatureMessages { + return &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: 1, + Messages: []*spectypes.PartialSignatureMessage{{SigningRoot: root}}, + } + } + record := func(ss *SignerStateForSlotRound, from peer.ID, root [32]byte) { + ss.Peer(from).recordRequestAuthRoot(root) + ss.World.recordRequestAuthRoot(root) + } + root := func(b byte) [32]byte { return [32]byte{b} } + + const peerA = peer.ID("A") + const peerB = peer.ID("B") + + t.Run("distinct roots accepted up to the bound, then further distinct roots are ignored", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + for i := 0; i < maxRequestAuthDistinctRoots; i++ { + r := root(byte(i + 1)) + require.NoError(t, validatePartialSignatureMessageLimit(raMsg(r), peerA, ss)) + record(ss, peerA, r) + } + + var valErr Error + err := validatePartialSignatureMessageLimit(raMsg(root(99)), peerA, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.False(t, valErr.reject) + }) + + t.Run("same-peer duplicate root is rejected, a relayed duplicate is ignored", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + r := root(1) + require.NoError(t, validatePartialSignatureMessageLimit(raMsg(r), peerA, ss)) + record(ss, peerA, r) + + var valErr Error + err := validatePartialSignatureMessageLimit(raMsg(r), peerA, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.True(t, valErr.reject) + + err = validatePartialSignatureMessageLimit(raMsg(r), peerB, ss) + require.ErrorIs(t, err, ErrTooManyPartialSigMessage) + require.True(t, errors.As(err, &valErr)) + require.False(t, valErr.reject) + }) + + t.Run("§5 preference roots do not consume the request-auth budget (and vice versa)", func(t *testing.T) { + ss := newSignerState(1, specqbft.FirstRound) + for i := 0; i < maxProposerPreferencesDistinctRoots; i++ { + ss.Peer(peerA).recordProposerPreferencesRoot(root(byte(100 + i))) + ss.World.recordProposerPreferencesRoot(root(byte(100 + i))) + } + // The §5 budget is spent; a request-auth root is still admitted. + require.NoError(t, validatePartialSignatureMessageLimit(raMsg(root(1)), peerA, ss)) + record(ss, peerA, root(1)) + // And the request-auth root did not consume the §5 budget's tracking. + require.Equal(t, 1, ss.World.requestAuthRootCount()) + require.Equal(t, maxProposerPreferencesDistinctRoots, ss.World.proposerPreferencesRootCount()) + }) +} + +// RequestAuthPartialSig, like the §5 preference type, is budgeted by distinct root — recording it +// must not consume the single pre-consensus bit in SeenMsgTypes that caps every other +// pre-consensus type at one message. +func TestSeenMsgTypes_RequestAuthDoesNotConsumePreConsensusBit(t *testing.T) { + var seen SeenMsgTypes + require.NoError(t, seen.RecordPartialSignatureMessage(&spectypes.PartialSignatureMessages{Type: spectypes.RequestAuthPartialSig})) + require.False(t, seen.reachedPreConsensusLimit()) + + require.NoError(t, seen.RecordPartialSignatureMessage(&spectypes.PartialSignatureMessages{Type: spectypes.PTCAttesterPartialSig})) + require.True(t, seen.reachedPreConsensusLimit()) +} diff --git a/message/validation/seen_msg_types.go b/message/validation/seen_msg_types.go index be5a69a9b6..9dd68014f6 100644 --- a/message/validation/seen_msg_types.go +++ b/message/validation/seen_msg_types.go @@ -82,9 +82,10 @@ func (c *SeenMsgTypes) RecordPartialSignatureMessage(messages *spectypes.Partial switch messages.Type { case spectypes.RandaoPartialSig, ssvtypes.SelectionProofPartialSig, ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, spectypes.AggregatorCommitteePartialSig, spectypes.PTCAttesterPartialSig: c.recordPreConsensus() - case spectypes.ProposerPreferencesPartialSig: - // Capped by distinct signing root rather than the single pre-consensus bit (SIP #94 §5); the root - // set is tracked on SignerState, so there is nothing to record in this type bitmask. + case spectypes.ProposerPreferencesPartialSig, spectypes.RequestAuthPartialSig: + // Capped by distinct signing root rather than the single pre-consensus bit (SIP #94 §5 and + // its issue #2962 request-auth extension); the root sets are tracked on SignerState, so + // there is nothing to record in this type bitmask. case spectypes.PostConsensusPartialSig: c.recordPostConsensus() default: diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 1c1012c579..40f992341a 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -53,6 +53,7 @@ func (s *SignerStateForSlotRound) Reset(slot phase0.Slot, round specqbft.Round) s.World.HashedProposalData = nil s.World.SeenDecidedMsgSignersCount = 0 s.World.SeenProposerPreferencesRoots = nil + s.World.SeenRequestAuthRoots = nil } // SignerState represents the state of a signer (an Operator running a Runner that performs partial-signing for @@ -73,6 +74,12 @@ type SignerState struct { // signer (SIP #94 §5): that type is capped by distinct root (up to maxProposerPreferencesDistinctRoots), // not by the single pre-consensus bit in SeenMsgTypes. nil until the first such message. SeenProposerPreferencesRoots [][32]byte + + // SeenRequestAuthRoots records the distinct RequestAuthV1 signing roots seen from this signer + // (issue #2962): like §5 preferences the type is capped by distinct root (up to + // maxRequestAuthDistinctRoots — one per configured builder), not by the single pre-consensus + // bit in SeenMsgTypes. nil until the first such message. + SeenRequestAuthRoots [][32]byte } // hasProposerPreferencesRoot reports whether root has already been seen from this signer. @@ -92,3 +99,21 @@ func (s *SignerState) recordProposerPreferencesRoot(root [32]byte) { } s.SeenProposerPreferencesRoots = append(s.SeenProposerPreferencesRoots, root) } + +// hasRequestAuthRoot reports whether the request-auth root has already been seen from this signer. +func (s *SignerState) hasRequestAuthRoot(root [32]byte) bool { + return slices.Contains(s.SeenRequestAuthRoots, root) +} + +// requestAuthRootCount returns the number of distinct request-auth roots seen from this signer. +func (s *SignerState) requestAuthRootCount() int { + return len(s.SeenRequestAuthRoots) +} + +// recordRequestAuthRoot adds the request-auth root to the seen set, skipping roots already present. +func (s *SignerState) recordRequestAuthRoot(root [32]byte) { + if slices.Contains(s.SeenRequestAuthRoots, root) { + return + } + s.SeenRequestAuthRoots = append(s.SeenRequestAuthRoots, root) +} diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 93ac4f266e..d25d658954 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -44,6 +44,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv/runner" "github.com/ssvlabs/ssv/protocol/v2/ssv/validator" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" registrystorage "github.com/ssvlabs/ssv/registry/storage" "github.com/ssvlabs/ssv/storage/basedb" ) @@ -87,6 +88,7 @@ type ControllerOptions struct { Graffiti []byte ProposerDelay time.Duration ProposerDelayEPBS time.Duration + Builders []gloas.BuilderEntry // worker flags WorkersCount int `yaml:"MsgWorkersCount" env:"MSG_WORKERS_COUNT" env-description:"Number of message processing workers"` @@ -195,24 +197,24 @@ func NewController(logger *zap.Logger, options ControllerOptions) *Controller { WorkersCount: options.WorkersCount, Buffer: options.QueueBufferSize, } - validatorCommonOpts := validator.NewCommonOptions( - options.NetworkConfig, - options.Network, - options.Beacon, - options.StorageMap, - options.BeaconSigner, - options.OperatorSigner, - options.DoppelgangerHandler, - options.NewDecidedHandler, - options.FullNode, - options.ExporterMode, - options.HistorySyncBatchSize, - options.GasLimit, - options.MessageValidator, - options.Graffiti, - options.ProposerDelay, - options.ProposerDelayEPBS, - ) + validatorCommonOpts := validator.NewCommonOptions(validator.CommonOptions{ + NetworkConfig: options.NetworkConfig, + Network: options.Network, + Beacon: options.Beacon, + Storage: options.StorageMap, + Signer: options.BeaconSigner, + OperatorSigner: options.OperatorSigner, + DoppelgangerHandler: options.DoppelgangerHandler, + NewDecidedHandler: options.NewDecidedHandler, + FullNode: options.FullNode, + ExporterMode: options.ExporterMode, + GasLimit: options.GasLimit, + MessageValidator: options.MessageValidator, + Graffiti: options.Graffiti, + ProposerDelay: options.ProposerDelay, + ProposerDelayEPBS: options.ProposerDelayEPBS, + Builders: options.Builders, + }, options.HistorySyncBatchSize) cacheTTL := 2 * options.NetworkConfig.EpochDuration() // #nosec G115 @@ -1256,6 +1258,11 @@ func SetupRunners( // §4-decided block root) and the §6 envelope runner (which reads it). proposedBlockRoots := ssv.NewProposedBlockRoots() + // requestAuthCache is shared between this validator's proposer-preferences runner (which writes + // each threshold-reconstructed builder request auth, issue #2962) and the proposer runner's §4 + // produce path (which will attach them once the produceBlockV4 POST migration lands). + requestAuthCache := ssv.NewRequestAuthCache() + runners := runner.ValidatorDutyRunners{} var err error for _, role := range runnersType { @@ -1346,6 +1353,8 @@ func SetupRunners( BaseRunnerOptions: baseOpts, FeeRecipientProvider: validatorStore, GasLimit: options.GasLimit, + Builders: options.Builders, + RequestAuthCache: requestAuthCache, }) default: return nil, fmt.Errorf("unexpected duty runner type: %s", role) diff --git a/protocol/v2/ssv/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go new file mode 100644 index 0000000000..19e87c32f6 --- /dev/null +++ b/protocol/v2/ssv/request_auth_cache.go @@ -0,0 +1,64 @@ +package ssv + +import ( + "maps" + "sync" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// requestAuthRetentionSlots bounds how long reconstructed request auths outlive their proposal +// slot. An auth is written up to the proposer lookahead ahead of its slot and consumed at (or just +// before) it — §4 bid requests and the epoch-prior submitBuilderPreferences — so entries for slots +// this far behind the newest write are dead weight. +const requestAuthRetentionSlots = 4 + +// RequestAuthCache holds, per (validator, proposal slot), the threshold-reconstructed +// SignedRequestAuthV1 for each configured builder relationship (issue #2962 B1), keyed by +// gloas.BuilderIdentity. The §5 slot sub-runners write on reconstruction quorum; the §4 produce +// path (and later the ahead-of-time submitBuilderPreferences) read. It is shared between a single +// validator's runners; lives in package ssv alongside ProposedBlockRoots for the same +// import-cycle reason. Safe for concurrent use. +type RequestAuthCache struct { + mu sync.Mutex + auths map[phase0.ValidatorIndex]map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1 +} + +func NewRequestAuthCache() *RequestAuthCache { + return &RequestAuthCache{auths: make(map[phase0.ValidatorIndex]map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1)} +} + +// Store records the reconstructed auth for the validator's proposal slot under the builder +// identity, and evicts slots more than the retention window behind the newest stored slot. +func (c *RequestAuthCache) Store(validatorIndex phase0.ValidatorIndex, slot phase0.Slot, builderIdentity string, auth *gloas.SignedRequestAuthV1) { + c.mu.Lock() + defer c.mu.Unlock() + + bySlot := c.auths[validatorIndex] + if bySlot == nil { + bySlot = make(map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1) + c.auths[validatorIndex] = bySlot + } + byBuilder := bySlot[slot] + if byBuilder == nil { + byBuilder = make(map[string]*gloas.SignedRequestAuthV1) + bySlot[slot] = byBuilder + } + byBuilder[builderIdentity] = auth + + for sl := range bySlot { + if slot > requestAuthRetentionSlots && sl < slot-requestAuthRetentionSlots { + delete(bySlot, sl) + } + } +} + +// Get returns a copy of the builder-identity → reconstructed-auth map for the validator's proposal +// slot; empty when nothing reconstructed yet. +func (c *RequestAuthCache) Get(validatorIndex phase0.ValidatorIndex, slot phase0.Slot) map[string]*gloas.SignedRequestAuthV1 { + c.mu.Lock() + defer c.mu.Unlock() + return maps.Clone(c.auths[validatorIndex][slot]) +} diff --git a/protocol/v2/ssv/request_auth_cache_test.go b/protocol/v2/ssv/request_auth_cache_test.go new file mode 100644 index 0000000000..b936990b6a --- /dev/null +++ b/protocol/v2/ssv/request_auth_cache_test.go @@ -0,0 +1,37 @@ +package ssv + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +func TestRequestAuthCache(t *testing.T) { + cache := NewRequestAuthCache() + authAt := func(slot phase0.Slot) *gloas.SignedRequestAuthV1 { + return &gloas.SignedRequestAuthV1{Message: &gloas.RequestAuthV1{Data: []byte("x"), Slot: slot}} + } + + require.Empty(t, cache.Get(1, 100)) + + cache.Store(1, 100, "builder-a", authAt(100)) + cache.Store(1, 100, "builder-b", authAt(100)) + cache.Store(2, 100, "builder-a", authAt(100)) + + require.Len(t, cache.Get(1, 100), 2) + require.Len(t, cache.Get(2, 100), 1) + require.Empty(t, cache.Get(3, 100)) + + // The returned map is a copy: mutating it must not affect the cache. + got := cache.Get(1, 100) + delete(got, "builder-a") + require.Len(t, cache.Get(1, 100), 2) + + // Slots more than the retention window behind the newest stored slot are evicted per validator. + cache.Store(1, 100+requestAuthRetentionSlots+1, "builder-a", authAt(100+requestAuthRetentionSlots+1)) + require.Empty(t, cache.Get(1, 100), "stale slot must be evicted") + require.Len(t, cache.Get(2, 100), 1, "other validators' slots are untouched") +} diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index f8e5603c0b..4509365de7 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -136,6 +136,12 @@ var ( observability.InstrumentName(observabilityNamespace, "envelope.build_match"), metric.WithUnit("{envelope}"), metric.WithDescription("decided Gloas execution-payload envelopes by whether this operator is the one that built them"))) + + requestAuthReconstructionCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "request_auth.reconstructions"), + metric.WithUnit("{auth}"), + metric.WithDescription("threshold-reconstructed Gloas direct-builder request auths (issue #2962)"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -154,15 +160,24 @@ func recordDutyOutcome(ctx context.Context, role spectypes.RunnerRole, outcome d )) } -// recordProposalBuildSource counts a submitted Gloas proposal by build source — self-build -// (BUILDER_INDEX_SELF_BUILD) vs external builder. Gloas-only: the decided bid is the same for every -// operator, unlike the pre-Gloas Blinded flag, which the distributed submit skews. -func recordProposalBuildSource(ctx context.Context, localBuild bool) { - source := "builder" - if localBuild { - source = "local" - } - proposalBuildSourceCounter.Add(ctx, 1, metric.WithAttributes(observability.BuildSourceAttribute(source))) +// proposalBuildSource is a submitted Gloas proposal's build source (issue #2962 E1). Today only the +// outcome is knowable — the GET produce doesn't expose why the BN self-built; the produce-POST +// migration will split buildSourceLocal by reason (no bid available / economics / builder auth +// unavailable, the latter fed by the request-auth cache). +type proposalBuildSource string + +const ( + // buildSourceBuilder — the decided bid commits to an external builder. + buildSourceBuilder proposalBuildSource = "builder" + // buildSourceLocal — the decided bid commits to BUILDER_INDEX_SELF_BUILD. + buildSourceLocal proposalBuildSource = "local" +) + +// recordProposalBuildSource counts a submitted Gloas proposal by build source. Gloas-only: the +// decided bid is the same for every operator, unlike the pre-Gloas Blinded flag, which the +// distributed submit skews. +func recordProposalBuildSource(ctx context.Context, source proposalBuildSource) { + proposalBuildSourceCounter.Add(ctx, 1, metric.WithAttributes(observability.BuildSourceAttribute(string(source)))) } // recordEnvelopeBuildMatch counts a decided §6 envelope by whether this operator's cached envelope @@ -180,6 +195,13 @@ func recordEnvelopeBuildMatch(ctx context.Context, self bool) { envelopeBuildMatchCounter.Add(ctx, 1, metric.WithAttributes(observability.EnvelopeBuildMatchAttribute(match))) } +// recordRequestAuthReconstruction counts a threshold-reconstructed direct-builder request auth +// (issue #2962). The inverse signal — an auth that never reached quorum — is measured where it +// bites: at the §4 produce path's cache lookup, once the produce-POST migration lands. +func recordRequestAuthReconstruction(ctx context.Context) { + requestAuthReconstructionCounter.Add(ctx, 1) +} + func recordPreConsensusDuration(ctx context.Context, duration time.Duration, role spectypes.RunnerRole) { preConsensusDurationHistogram.Record(ctx, duration.Seconds(), metric.WithAttributes( diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 6df989acbe..40754505b1 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -546,7 +546,7 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo logger.Error(errMsg, fields.Slot(cd.Duty.Slot), zap.Error(err)) finishErr = fmt.Errorf("%s: %w", errMsg, err) } else { - recordProposalBuildSource(ctx, selfBuild(block)) + recordProposalBuildSource(ctx, gloasBuildSource(block)) finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) } @@ -571,6 +571,14 @@ func selfBuild(block *gloas.BeaconBlock) bool { return bid != nil && bid.Message != nil && bid.Message.BuilderIndex == gloas.BuilderIndexSelfBuild } +// gloasBuildSource classifies a decided Gloas block for the build-source telemetry (issue #2962 E1). +func gloasBuildSource(block *gloas.BeaconBlock) proposalBuildSource { + if selfBuild(block) { + return buildSourceLocal + } + return buildSourceBuilder +} + // recordDecidedBlockRoot stores the §4-decided block's root for the §6 envelope runner and its // value-check to read (SIP #94 §6). No-op when no envelope runner shares the store. func (r *ProposerRunner) recordDecidedBlockRoot(slot phase0.Slot, block *gloas.BeaconBlock) error { diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 29fd774b1e..411db9e852 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -15,6 +15,7 @@ import ( "github.com/ssvlabs/ssv/observability/log/fields" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" + "github.com/ssvlabs/ssv/protocol/v2/ssv" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" "github.com/ssvlabs/ssv/ssvsigner/ekm" @@ -51,10 +52,11 @@ type ProposerPreferencesRunner struct { pending map[phase0.Slot][]*spectypes.PartialSignatureMessages } -// maxPendingRootsPerSigner mirrors message validation's maxProposerPreferencesDistinctRoots: the wire -// admits at most that many distinct §5 signing roots per (slot, signer), so the pending stash never -// needs to retain more per signer. -const maxPendingRootsPerSigner = 4 +// maxPendingRootsPerSigner is how many stashed partials one signer can legitimately account for per +// proposal slot: the wire admits at most gloas.MaxProposerPreferencesDistinctRoots distinct §5 +// preference roots plus gloas.MaxRequestAuthDistinctRoots distinct request-auth roots per +// (slot, signer) — the same shared constants message validation enforces. +const maxPendingRootsPerSigner = gloas.MaxProposerPreferencesDistinctRoots + gloas.MaxRequestAuthDistinctRoots // ProposerPreferencesRunnerOptions bundles the dependencies required by NewProposerPreferencesRunner. type ProposerPreferencesRunnerOptions struct { @@ -62,6 +64,14 @@ type ProposerPreferencesRunnerOptions struct { FeeRecipientProvider feeRecipientProvider GasLimit uint64 + + // Builders is the cluster's direct-builder list (issue #2962, validated at startup): for each + // authenticatable entry the slot sub-runners additionally threshold-sign a RequestAuthV1 per + // upcoming proposal slot. Empty (the default) disables the overlay entirely. + Builders []gloas.BuilderEntry + // RequestAuthCache receives each reconstructed SignedRequestAuthV1, shared with the validator's + // proposer runner so the §4 produce path can attach auths at proposal time. + RequestAuthCache *ssv.RequestAuthCache } func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner, error) { @@ -99,6 +109,10 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za if prev, ok := r.bySlot[slot]; ok { sub.submittedPreferences = prev.submittedPreferences sub.broadcastPreferences = prev.broadcastPreferences + // Auth roots are re-emission-invariant: never re-broadcast one already out, never redo a + // reconstruction already cached. + sub.broadcastAuthRoots = prev.broadcastAuthRoots + sub.reconstructedAuthRoots = prev.reconstructedAuthRoots if prev.hasDutyRunning() { prev.markDutyNotRequired() // superseded by the re-emission, not stuck } @@ -108,11 +122,12 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za return err } - // Replay the stashed partials for this proposal slot. Peers broadcast their §5 partial once, at - // their own emission tick, so it may predate this (re)start; the stash is the only recovery path — - // there is no re-broadcast, and message validation would dedup one by signing root anyway. A - // stashed partial that doesn't match the freshly frozen preference fails signature verification - // inside the sub-runner and is skipped. + // Replay the stashed partials for this proposal slot. Peers broadcast their §5-role partials + // once, at their own emission tick, so they may predate this (re)start; the stash is the only + // recovery path — there is no re-broadcast, and message validation would dedup one by signing + // root anyway. A stashed preference partial that doesn't match the freshly frozen preference + // fails signature verification inside the sub-runner and is skipped; a stashed request-auth + // partial outside the freshly frozen auth-root set is skipped the same way. if sub.hasDutyRunning() { for _, stashed := range r.pending[slot] { if err := sub.ProcessPreConsensus(ctx, logger, stashed); err != nil { @@ -125,9 +140,9 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za } func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { - // Stash every §5 partial (bounded, deduplicated), even when a sub-runner exists: a later - // re-emission replaces the sub-runner and its container, and peers won't re-broadcast, so the - // stash is what re-seeds the replacement (see StartNewDuty). + // Stash every §5-role partial — preference and request-auth alike (bounded, deduplicated) — even + // when a sub-runner exists: a later re-emission replaces the sub-runner and its containers, and + // peers won't re-broadcast, so the stash is what re-seeds the replacement (see StartNewDuty). r.stashPending(signedMsg) sub, ok := r.bySlot[signedMsg.Slot] @@ -140,12 +155,14 @@ func (r *ProposerPreferencesRunner) ProcessPreConsensus(ctx context.Context, log return sub.ProcessPreConsensus(ctx, logger, signedMsg) } -// stashPending records a §5 partial for its proposal slot so StartNewDuty can replay it. Duplicates -// by (signer, signing root) are skipped; a slot's stash is capped at the committee size times the -// wire's per-signer distinct-root cap, so a full stash can only mean noise. +// stashPending records a §5-role partial for its proposal slot so StartNewDuty can replay it. +// Duplicates by (signer, signing root) are skipped — roots are globally distinct across the two +// message types (different signing domains), so one keyspace serves both; a slot's stash is capped +// at the committee size times the wire's combined per-signer distinct-root budget, so a full stash +// can only mean noise. func (r *ProposerPreferencesRunner) stashPending(signedMsg *spectypes.PartialSignatureMessages) { if signedMsg == nil || len(signedMsg.Messages) != 1 { - return // §5 partials carry exactly one message (enforced by message validation) + return // §5-role partials (preference and request-auth alike) carry exactly one message } msg := signedMsg.Messages[0] stash := r.pending[signedMsg.Slot] @@ -213,7 +230,7 @@ func (r *ProposerPreferencesRunner) expectedPreConsensusRootsAndDomain() ([]ssz. } func (r *ProposerPreferencesRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { - return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for proposer preferences") + return nil, spectypes.DomainError, fmt.Errorf("no post-consensus roots for proposer preferences") } func (r *ProposerPreferencesRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { @@ -308,6 +325,41 @@ type proposerPreferencesSlotRunner struct { // duplicate, penalizing this operator's gossip score for nothing (issue #2934); the dispatcher's // stash replay re-seeds the replacement instead, our own first partial included. broadcastPreferences *gloas.ProposerPreferences + + // builders is the cluster's direct-builder list (issue #2962 B1): for each authenticatable entry + // executeDuty freezes and threshold-signs a RequestAuthV1{data, proposal_slot} alongside the §5 + // preference. Empty disables the request-auth round entirely. + builders []gloas.BuilderEntry + requestAuthCache *ssv.RequestAuthCache + + // requestAuths maps each frozen RequestAuthV1's signing root to the object and its builder, set + // when the duty executes; incoming RequestAuthPartialSig messages are admitted only against + // these roots. nil means the duty has not executed here yet. + requestAuths map[[32]byte]*frozenRequestAuth + + // requestAuthContainer collects request-auth partials separately from the §5 preference round: + // the two sign under different domains, so they cannot share the base pre-consensus round, and + // auth collection legitimately keeps running after the preference round concludes the duty. + requestAuthContainer *ssv.PartialSigContainer + + // broadcastAuthRoots records the auth roots this operator already broadcast a partial for, + // carried across sub-runner replacements like broadcastPreferences: auth roots don't depend on + // dependent_root, so a re-emission re-produces the identical root and a re-broadcast would only + // get this operator gossip-penalized as a same-peer duplicate (issue #2934). + broadcastAuthRoots map[[32]byte]struct{} + + // reconstructedAuthRoots records the auth roots already reconstructed into the cache, carried + // across sub-runner replacements like broadcastAuthRoots: a replacement's fresh container would + // otherwise re-reach quorum from the stash replay and redo the reconstruction (and its metric) + // for a value that cannot have changed. + reconstructedAuthRoots map[[32]byte]struct{} +} + +// frozenRequestAuth pairs a frozen RequestAuthV1 with the builder relationship it authenticates. +type frozenRequestAuth struct { + auth *gloas.RequestAuthV1 + identity string // gloas.BuilderIdentity — the RequestAuthCache key + url string // for logging } func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { @@ -318,12 +370,16 @@ func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *pr Share: opts.Share, }, - beacon: opts.Beacon, - network: opts.Network, - signer: opts.Signer, - operatorSigner: opts.OperatorSigner, - feeRecipientProvider: opts.FeeRecipientProvider, - gasLimit: opts.GasLimit, + beacon: opts.Beacon, + network: opts.Network, + signer: opts.Signer, + operatorSigner: opts.OperatorSigner, + feeRecipientProvider: opts.FeeRecipientProvider, + gasLimit: opts.GasLimit, + builders: opts.Builders, + requestAuthCache: opts.RequestAuthCache, + broadcastAuthRoots: map[[32]byte]struct{}{}, + reconstructedAuthRoots: map[[32]byte]struct{}{}, } } @@ -332,12 +388,18 @@ func (r *proposerPreferencesSlotRunner) StartNewDuty(ctx context.Context, logger if err != nil { return err } - // Clear any prior observation; executeDuty re-freezes it, so a not-yet-executed duty stays nil. + // Clear any prior observations; executeDuty re-freezes them, so a not-yet-executed duty stays nil. r.proposerPreferences = nil + r.requestAuths = nil + r.requestAuthContainer = ssv.NewPartialSigContainer(quorum) return r.baseStartNewNonBeaconDuty(ctx, logger, r, validatorDuty, quorum) } func (r *proposerPreferencesSlotRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) (err error) { + if signedMsg.Type == spectypes.RequestAuthPartialSig { + return r.processRequestAuthPartial(ctx, logger, signedMsg) + } + hasQuorum, roots, err := r.basePreConsensusMsgProcessing(ctx, logger, r, signedMsg) if errors.Is(err, ErrNoDutyAssigned) || errors.Is(err, ErrRunningDutySucceeded) { // A late message for a concluded slot is retryable (the sub-runner lingers until evicted). @@ -409,7 +471,7 @@ func (r *proposerPreferencesSlotRunner) expectedPreConsensusRootsAndDomain() ([] } func (r *proposerPreferencesSlotRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { - return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for proposer preferences") + return nil, spectypes.DomainError, fmt.Errorf("no post-consensus roots for proposer preferences") } func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { @@ -419,6 +481,11 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger } proposalSlot := validatorDuty.DutySlot() + // The request-auth round rides every execution of the §5 duty, ahead of the preference logic so + // none of its early-return paths (unchanged preference, in-flight broadcast) can skip it. It + // never fails the duty: the overlay is opt-in and the §5 preference must not depend on it. + r.runRequestAuthRound(ctx, logger, validatorDuty, proposalSlot) + preferences, err := r.buildProposerPreferences(ctx, proposalSlot) if err != nil { // Building hits the beacon node (dependent-root fetch) and validator config (fee recipient); @@ -478,6 +545,132 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger return nil } +// runRequestAuthRound freezes one RequestAuthV1{data, proposal_slot} per authenticatable configured +// builder (issue #2962 B1): records each auth's signing root so incoming partials can be admitted, +// and signs and broadcasts this operator's partial — once per root, across re-emissions (auth roots +// are re-emission-invariant, see broadcastAuthRoots). Per-builder failures are logged and skipped, +// never failing the §5 duty: a builder whose auth misses quorum is simply not contactable for the +// slot, and the enshrined flow (gossip bids, self-build) stays available. +func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, logger *zap.Logger, validatorDuty *spectypes.ValidatorDuty, proposalSlot phase0.Slot) { + if len(r.builders) == 0 { + return + } + + // DomainRequestAuth is genesis-style (computed locally by the beacon adapter, no BN call); the + // epoch argument is ignored for it. + domain, err := r.beacon.DomainData(ctx, r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + if err != nil { + logger.Warn("request auth skipped: could not get domain data", fields.Slot(proposalSlot), zap.Error(err)) + return + } + + if r.requestAuths == nil { + r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) + } + for i := range r.builders { + entry := &r.builders[i] + data, err := entry.AuthDataBytes() + if err != nil || len(data) == 0 { + // The default (empty-URL) entry is not authenticatable, and invalid auth data is + // rejected at startup — nothing to sign either way. + continue + } + auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + if err != nil { + logger.Warn("request auth skipped: could not compute signing root", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + r.requestAuths[root] = &frozenRequestAuth{auth: auth, identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} + + if _, done := r.broadcastAuthRoots[root]; done { + // A prior incarnation of this slot already broadcast this exact auth; the dispatcher's + // stash replay and live partials complete its quorum, a re-broadcast would only be + // dropped as a same-peer duplicate. + continue + } + msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainRequestAuth), domain) + if err != nil { + logger.Warn("request auth skipped: could not sign", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + msgs := &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{msg}, + } + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + logger.Warn("request auth skipped: could not broadcast partial", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + r.broadcastAuthRoots[root] = struct{}{} + } +} + +// processRequestAuthPartial collects request-auth partials into their own container and, on the +// first quorum for a root, reconstructs the builder-facing SignedRequestAuthV1 into the shared +// cache. Unlike the §5 preference round it has no succeeded-gate: the preference submission +// concluding the duty must not stop auth collection, which legitimately continues until the +// proposal slot (the sub-runner lingers until evicted). +func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + if !r.hasDutyAssigned() { + return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) + } + if err := r.validatePartialSigMsg(signedMsg, r.State.CurrentDuty.DutySlot()); err != nil { + return fmt.Errorf("invalid request-auth partial: %w", err) + } + if len(signedMsg.Messages) != 1 { + return errors.New("request-auth partial must carry exactly one message") + } + msg := signedMsg.Messages[0] + + if r.requestAuths == nil { + // Duty assigned but not executed here yet (or no builders configured): retryable, so a + // partial racing the duty start also lands via the queue replay and the dispatcher stash. + return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, errors.New("no frozen request auths"))) + } + frozen, ok := r.requestAuths[msg.SigningRoot] + if !ok { + // A root we didn't freeze: the sender's builder list or auth-data bytes diverge from ours; + // whatever quorum it can reach forms on the operators that share its config. + return fmt.Errorf("unknown request-auth signing root %x", msg.SigningRoot) + } + if _, done := r.reconstructedAuthRoots[msg.SigningRoot]; done { + // Already reconstructed and cached, possibly by a prior incarnation of this slot — the + // carried marker keeps a replacement's stash replay from redoing the work; late partials + // add nothing. + return nil + } + + // quorum returns true only once per root (the first time it is reached). + hasQuorum, _ := r.basePartialSigMsgProcessing(signedMsg, r.requestAuthContainer) + if !hasQuorum { + return nil + } + + fullSig, err := r.State.ReconstructBeaconSig(r.requestAuthContainer, msg.SigningRoot, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) + if err != nil { + // If the reconstructed signature is invalid, surface which partial signatures were at fault. + r.FallBackAndVerifyEachSignature(r.requestAuthContainer, msg.SigningRoot, r.GetShare().Committee, r.GetShare().ValidatorIndex) + return fmt.Errorf("got request-auth quorum but it has invalid signatures: %w", err) + } + var signature phase0.BLSSignature + copy(signature[:], fullSig) + + r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} + if r.requestAuthCache != nil { + r.requestAuthCache.Store(r.GetShare().ValidatorIndex, frozen.auth.Slot, frozen.identity, + &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature}) + } + recordRequestAuthReconstruction(ctx) + logger.Info("✔️ reconstructed builder request auth", + fields.Slot(frozen.auth.Slot), zap.String("builder_url", frozen.url)) + return nil +} + // buildProposerPreferences assembles the preference for the proposal slot from this operator's own // view: fee recipient and target gas limit from validator config (matching validator registration), // and the dependent_root of the proposer duties for the proposal slot's epoch — the seed that fixed diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index 2ffadfc597..e012f95fb9 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -155,7 +155,7 @@ func TestProposerPreferencesRunner_stashPending(t *testing.T) { disp.stashPending(msg(1, 0xbb)) // same signer, another root: kept require.Len(t, disp.pending[slot], 3) - for i := range 16 { // well beyond the cap + for i := range 2*maxPendingRootsPerSigner + 8 { // well beyond the cap disp.stashPending(msg(spectypes.OperatorID(10+i), 0xcc)) } require.Len(t, disp.pending[slot], 2*maxPendingRootsPerSigner) diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index 7f025b7bb9..bdb9035d41 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -147,7 +147,7 @@ func (r *PTCAttesterRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot } func (r *PTCAttesterRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { - return nil, [4]byte{}, fmt.Errorf("no post-consensus roots for PTC attestation") + return nil, spectypes.DomainError, fmt.Errorf("no post-consensus roots for PTC attestation") } func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go new file mode 100644 index 0000000000..700bf9f966 --- /dev/null +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -0,0 +1,249 @@ +package runner + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" + spectestingutils "github.com/ssvlabs/ssv-spec/types/testingutils" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/ssv" + protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/ssvsigner/ekm" +) + +// broadcastPartialSigTypes decodes the captured broadcasts into their partial-sig message types. +func broadcastPartialSigTypes(t *testing.T, msgs []*spectypes.SignedSSVMessage) map[spectypes.PartialSigMsgType]int { + t.Helper() + counts := map[spectypes.PartialSigMsgType]int{} + for _, signed := range msgs { + psigMsgs := &spectypes.PartialSignatureMessages{} + require.NoError(t, psigMsgs.Decode(signed.SSVMessage.Data)) + counts[psigMsgs.Type]++ + } + return counts +} + +// End-to-end request-auth convergence riding the §5 duty (issue #2962 B1): executing the duty +// freezes and broadcasts one auth partial per authenticatable builder (the default entry excluded); +// stashed peer partials replay into the round; quorum reconstructs the SignedRequestAuthV1 into the +// shared cache; re-emissions never re-broadcast (auth roots are re-emission-invariant); and a root +// outside the frozen set (config divergence) is a hard error. The §5 preference flow must conclude +// exactly as without builders. +func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + cfg := cloneTestNetworkConfig() + const quorum = 3 + + bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} + network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) + cache := ssv.NewRequestAuthCache() + + builders := []gloas.BuilderEntry{ + {URL: "https://builder-a.example.com"}, // auth data defaults to the URL bytes + {URL: "https://builder-b.example.com", AuthData: "0x010203"}, // explicit pre-agreed bytes + {}, // the default entry: unsigned preferences only, no auth round + } + require.NoError(t, gloas.ValidateBuilderEntries(builders)) + + runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: network, + Signer: ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()), + OperatorSigner: spectestingutils.NewOperatorSigner(keySet, 1), + }, + FeeRecipientProvider: fixedFeeRecipientProvider{addr: bellatrix.ExecutionAddress{0xfe}}, + GasLimit: 36_000_000, + Builders: builders, + RequestAuthCache: cache, + }) + require.NoError(t, err) + disp := runnerIface.(*ProposerPreferencesRunner) + + proposalSlot := cfg.EstimatedCurrentSlot() + 5 + duty := &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + } + + // peerAuthPartial signs the auth every operator derives from the shared builder config, as peer opID. + peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID, data []byte) *spectypes.PartialSignatureMessages { + t.Helper() + auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + require.NoError(t, err) + sig := keySet.Shares[opID].SignByte(root[:]) + return &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: opID, + ValidatorIndex: share.ValidatorIndex, + }}, + } + } + builderAData := []byte("https://builder-a.example.com") + builderBData := []byte{0x01, 0x02, 0x03} + + ctx := context.Background() + logger := zap.NewNop() + + // Builder A's peer partials arrive before our duty (emission skew): retryable, stashed. + for _, op := range []spectypes.OperatorID{2, 3, 4} { + err := disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, op, builderAData)) + require.Error(t, err) + require.True(t, IsRetryable(err)) + } + + // Our emission: one preference partial plus one auth partial per authenticatable builder go out, + // the stash replays builder A's peer partials to quorum, and its auth lands in the cache. + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + types := broadcastPartialSigTypes(t, network.BroadcastedMsgs) + require.Equal(t, 1, types[spectypes.ProposerPreferencesPartialSig]) + require.Equal(t, 2, types[spectypes.RequestAuthPartialSig], "one auth partial per authenticatable builder; the default entry signs nothing") + + auths := cache.Get(share.ValidatorIndex, proposalSlot) + require.Len(t, auths, 1, "builder A reached quorum via stash replay") + authA := auths[gloas.BuilderIdentity("https://builder-a.example.com", builderAData)] + require.NotNil(t, authA) + require.Equal(t, builderAData, authA.Message.Data) + require.Equal(t, proposalSlot, authA.Message.Slot) + + // The §5 preference flow concluded normally alongside (stash had no preference partials, so no + // submit yet — its quorum is driven separately below to prove full independence). + require.Empty(t, bn.submitted) + + // Builder B's peer partials arrive live; its auth reconstructs too. + for _, op := range []spectypes.OperatorID{2, 3, 4} { + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, op, builderBData))) + } + auths = cache.Get(share.ValidatorIndex, proposalSlot) + require.Len(t, auths, 2) + require.NotNil(t, auths[gloas.BuilderIdentity("https://builder-b.example.com", builderBData)]) + + // A re-emission for the same slot re-freezes but never re-broadcasts an auth (roots are + // re-emission-invariant and already out) — only the §5 side decides re-broadcast on its own rules. + broadcastsBefore := len(network.BroadcastedMsgs) + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + types = broadcastPartialSigTypes(t, network.BroadcastedMsgs[broadcastsBefore:]) + require.Zero(t, types[spectypes.RequestAuthPartialSig], "re-emission must not re-broadcast auth partials") + + // A partial over a root we never froze (divergent peer config) is a hard error, not retryable. + err = disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, 2, []byte("https://rogue.example.com"))) + require.ErrorContains(t, err, "unknown request-auth signing root") + require.False(t, IsRetryable(err)) +} + +// The request-auth round must not disturb the §5 preference lifecycle: with builders configured, +// the preference still submits on its own quorum, and auth collection keeps running after the §5 +// duty has already succeeded (no succeeded-gate on the auth path). +func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing.T) { + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + cfg := cloneTestNetworkConfig() + const quorum = 3 + const gasLimit = 36_000_000 + feeRecipient := bellatrix.ExecutionAddress{0xfe} + + bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} + network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) + cache := ssv.NewRequestAuthCache() + builders := []gloas.BuilderEntry{{URL: "https://builder-a.example.com"}} + + runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: network, + Signer: ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()), + OperatorSigner: spectestingutils.NewOperatorSigner(keySet, 1), + }, + FeeRecipientProvider: fixedFeeRecipientProvider{addr: feeRecipient}, + GasLimit: gasLimit, + Builders: builders, + RequestAuthCache: cache, + }) + require.NoError(t, err) + disp := runnerIface.(*ProposerPreferencesRunner) + + proposalSlot := cfg.EstimatedCurrentSlot() + 5 + duty := &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + } + require.NoError(t, disp.StartNewDuty(context.Background(), zap.NewNop(), duty, quorum)) + + // Drive the §5 preference to quorum first: the duty succeeds and submits. + peerPreferencePartial := func(t *testing.T, opID spectypes.OperatorID) *spectypes.PartialSignatureMessages { + t.Helper() + prefs := &gloas.ProposerPreferences{ + DependentRoot: bn.dependentRoot, + ProposalSlot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + FeeRecipient: feeRecipient, + TargetGasLimit: gasLimit, + } + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainProposerPreferences)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(prefs, domain) + require.NoError(t, err) + sig := keySet.Shares[opID].SignByte(root[:]) + return &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: opID, + ValidatorIndex: share.ValidatorIndex, + }}, + } + } + ctx := context.Background() + logger := zap.NewNop() + for _, op := range []spectypes.OperatorID{2, 3, 4} { + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerPreferencePartial(t, op))) + } + require.Len(t, bn.submitted, 1, "§5 preference must submit on its quorum") + + // Auth partials arriving after the §5 success must still be collected and reconstructed. + authData := []byte("https://builder-a.example.com") + auth := &gloas.RequestAuthV1{Data: authData, Slot: proposalSlot} + domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + require.NoError(t, err) + for _, op := range []spectypes.OperatorID{2, 3, 4} { + sig := keySet.Shares[op].SignByte(root[:]) + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: op, + ValidatorIndex: share.ValidatorIndex, + }}, + })) + } + require.Len(t, cache.Get(share.ValidatorIndex, proposalSlot), 1, + "auth must reconstruct even after the §5 preference concluded the duty") +} diff --git a/protocol/v2/ssv/runner/validator_registration.go b/protocol/v2/ssv/runner/validator_registration.go index 9cee5f9746..93906161d3 100644 --- a/protocol/v2/ssv/runner/validator_registration.go +++ b/protocol/v2/ssv/runner/validator_registration.go @@ -189,7 +189,7 @@ func (r *ValidatorRegistrationRunner) expectedPreConsensusRootsAndDomain() ([]ss // expectedPostConsensusRootsAndDomain an INTERNAL function, returns the expected post-consensus roots to sign func (r *ValidatorRegistrationRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { - return nil, [4]byte{}, fmt.Errorf("no post consensus roots for validator registration") + return nil, spectypes.DomainError, fmt.Errorf("no post consensus roots for validator registration") } func (r *ValidatorRegistrationRunner) executeDuty(ctx context.Context, logger *zap.Logger, duty spectypes.Duty) error { diff --git a/protocol/v2/ssv/validator/opts.go b/protocol/v2/ssv/validator/opts.go index c630a06755..20e9e024fc 100644 --- a/protocol/v2/ssv/validator/opts.go +++ b/protocol/v2/ssv/validator/opts.go @@ -15,6 +15,7 @@ import ( qbftctrl "github.com/ssvlabs/ssv/protocol/v2/qbft/controller" "github.com/ssvlabs/ssv/protocol/v2/ssv/runner" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) // defaultValidatorQueueSize is the default capacity of the per-validator-per-role @@ -52,51 +53,17 @@ type CommonOptions struct { Graffiti []byte ProposerDelay time.Duration ProposerDelayEPBS time.Duration + Builders []gloas.BuilderEntry } -func NewCommonOptions( - networkConfig *networkconfig.Network, - network protocolp2p.Network, - beacon beacon.BeaconNode, - storage *storage.ParticipantStores, - signer ekm.BeaconSigner, - operatorSigner ssvtypes.OperatorSigner, - doppelgangerHandler runner.DoppelgangerProvider, - newDecidedHandler qbftctrl.NewDecidedHandler, - fullNode bool, - exporterMode bool, - historySyncBatchSize int, - gasLimit uint64, - messageValidator validation.MessageValidator, - graffiti []byte, - proposerDelay time.Duration, - proposerDelayEPBS time.Duration, -) *CommonOptions { - result := &CommonOptions{ - NetworkConfig: networkConfig, - Network: network, - Beacon: beacon, - Storage: storage, - Signer: signer, - OperatorSigner: operatorSigner, - DoppelgangerHandler: doppelgangerHandler, - NewDecidedHandler: newDecidedHandler, - FullNode: fullNode, - ExporterMode: exporterMode, - QueueSize: defaultValidatorQueueSize, - GasLimit: gasLimit, - MessageValidator: messageValidator, - Graffiti: graffiti, - ProposerDelay: proposerDelay, - ProposerDelayEPBS: proposerDelayEPBS, +// NewCommonOptions finalizes a CommonOptions literal: it owns QueueSize (any caller-set value is +// overwritten with the default, bumped for full nodes so history-sync batches can be pushed whole). +func NewCommonOptions(opts CommonOptions, historySyncBatchSize int) *CommonOptions { + opts.QueueSize = defaultValidatorQueueSize + if opts.FullNode { + opts.QueueSize = max(opts.QueueSize, historySyncBatchSize*2) } - - // If full node, increase the queue size to make enough room for history sync batches to be pushed whole. - if fullNode { - result.QueueSize = max(result.QueueSize, historySyncBatchSize*2) - } - - return result + return &opts } func (o *CommonOptions) NewOptions( From 724eafc943dc9a73db57eee389f7d77f6beeb040 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 29 Jul 2026 09:03:18 +0300 Subject: [PATCH 123/150] =?UTF-8?q?gloas:=20#2962=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20clock-anchored=20auth=20cache,=20shared-token=20bui?= =?UTF-8?q?lders,=20budget=20=3D=20entry=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestAuthCache eviction is now measured against the current slot instead of the slot being written: §5 writes land up to a proposer lookahead ahead, so retention anchored to the (arbitrarily future) write slot could evict a sibling lookahead slot that was still ahead. The cache also drops its redundant per-validator key — it is one instance per validator, like ProposedBlockRoots. A frozen request auth now carries every configured builder relationship behind its signing root: the root derives from (data, slot) alone, so distinct (URL, AuthData) entries sharing one pre-agreed token converge on one root and one reconstruction — previously last-write-wins left all but one of them silently unauthenticated. The single broadcast per root is unchanged. The request-auth distinct-root budget drops from entry cap + 4 to the entry cap exactly: the builder list is startup-only and auth roots are dependent_root-independent, so no in-window churn exists for headroom to cover — and wire validation being config-independent, every extra admitted root was pure surface for clusters that never opt in. Also: a runner-layer validator-index guard on auth partials (the auth root, unlike the §5 preference, doesn't bind the index); nodes with Builders configured and a remote signer now warn once at startup and disable the overlay locally instead of warning per builder per emission forever; GetSpecDir walks the module tree once; and a conciseness pass over the new surface's comments, deduplicating rationale to single homes. --- beacon/goclient/signing.go | 10 +- cli/operator/node.go | 7 ++ config/config.example.yaml | 4 +- docs/EXTERNAL_BUILDERS.md | 3 + ibft/storage/testutils.go | 20 ++- message/validation/const.go | 12 +- message/validation/partial_validation.go | 8 +- message/validation/signer_state.go | 5 +- operator/validator/controller.go | 8 +- protocol/v2/ssv/request_auth_cache.go | 63 +++++----- protocol/v2/ssv/request_auth_cache_test.go | 37 +++--- .../v2/ssv/runner/proposer_preferences.go | 114 +++++++++--------- protocol/v2/ssv/runner/request_auth_test.go | 25 ++-- protocol/v2/types/gloas/builder_entry.go | 40 +++--- protocol/v2/types/gloas/builder_entry_test.go | 12 +- 15 files changed, 185 insertions(+), 183 deletions(-) diff --git a/beacon/goclient/signing.go b/beacon/goclient/signing.go index 9b8b80d759..b63240e235 100644 --- a/beacon/goclient/signing.go +++ b/beacon/goclient/signing.go @@ -55,11 +55,11 @@ func (gc *GoClient) DomainData( ) (phase0.Domain, error) { switch domain { case spectypes.DomainApplicationBuilder, spectypes.DomainRequestAuth: - // Application-namespace domains are constructed from the connected network's genesis fork - // version with a zero genesis-validators root (compute_domain with defaults), never from a - // fork-versioned state: DomainApplicationBuilder (pre-Gloas validator registrations) and - // DomainRequestAuth (builder-specs' Gloas DOMAIN_REQUEST_AUTH, the direct-builder request - // auth — 0x0b000001, not the beacon DomainBeaconBuilder 0x0b000000). + // Application-namespace domains derive from the network's genesis fork version with a zero + // genesis-validators root (compute_domain with defaults), never from a fork-versioned + // state: DomainApplicationBuilder (pre-Gloas validator registrations) and DomainRequestAuth + // (the Gloas direct-builder request auth — 0x0b000001, not the beacon DomainBeaconBuilder + // 0x0b000000). var appDomain phase0.Domain forkData := phase0.ForkData{ CurrentVersion: gc.getBeaconConfig().GenesisForkVersion, diff --git a/cli/operator/node.go b/cli/operator/node.go index bfb71a20c4..aa0d9e9949 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -232,6 +232,13 @@ func newNode( ) (_ *node, err error) { usingSSVSigner := res.usingSSVSigner + if len(cfg.Builders) > 0 && usingSSVSigner { + // Web3Signer has no request-auth type, so this operator could never contribute an auth + // partial — warn once here instead of once per builder per emission in the runner. + logger.Warn("Builders configured with a remote signer: request-auth signing is unsupported there, disabling the direct-builder overlay on this operator (the cluster still reconstructs auths while at most f operators are remote-signing)") + cfg.Builders = nil + } + identity, err := resolveOperatorIdentity(ctx, logger, cfg, res) if err != nil { return nil, err diff --git a/config/config.example.yaml b/config/config.example.yaml index d794314d6b..6daeb67c70 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -63,7 +63,9 @@ OperatorPrivateKey: # ALL operators of every committee sharing a validator: AuthData (defaulting to the UTF-8 bytes of URL, # exactly as written) is threshold-signed, so any divergence silently disables that builder. At most 8 # entries; one entry may have an empty URL to set default preferences for any contacted builder. -# Monetary values are Gwei. See docs/EXTERNAL_BUILDERS.md. +# Monetary values are Gwei. The bid-selection knobs (BuilderBoostFactor, MaxTrustedBid, MinBid, +# MaxExecutionPayment, PubKey) are validated but NOT YET HONORED — they take effect with the +# produceBlockV4 POST migration (beacon-APIs#625). See docs/EXTERNAL_BUILDERS.md. # Builders: # - URL: "https://builder.example.com" # # AuthData: "0x..." # omit to default to the URL bytes diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index 372a021616..6ec03e64c4 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -39,6 +39,9 @@ byte-identical `data`: signing, but divergence makes the cluster's effective bid policy depend on which operator leads the round — keep them identical too. They take effect with the produceBlockV4 POST migration (beacon-APIs#625, pending upstream). +- Remote-signing operators (Web3Signer) cannot produce request-auth partials — there is no request-auth + signing type there yet. A node with `Builders` set and a remote signer warns at startup and disables the + overlay locally; the cluster still reconstructs auths while at most `f` operators are remote-signing. ## How to use diff --git a/ibft/storage/testutils.go b/ibft/storage/testutils.go index cd219c1c95..d6f37d5c5b 100644 --- a/ibft/storage/testutils.go +++ b/ibft/storage/testutils.go @@ -229,7 +229,11 @@ func GetSpecDir(path, module string) (string, error) { return "", errors.New("could not get current directory") } } - goModFile, err := getGoModFile(path) + root, err := findGoModDir(path) + if err != nil { + return "", err + } + goModFile, err := parseGoModFile(root) if err != nil { return "", errors.New("could not get go.mod file") } @@ -252,10 +256,6 @@ func GetSpecDir(path, module string) (string, error) { // (go.mod semantics: a replacement path without a version must be a directory). dir := modPath if !filepath.IsAbs(dir) { - root, err := findGoModDir(path) - if err != nil { - return "", err - } dir = filepath.Join(root, dir) } if _, err := os.Stat(dir); err != nil { @@ -339,23 +339,17 @@ func findGoModDir(path string) (string, error) { } } -func getGoModFile(path string) (*modfile.File, error) { +// parseGoModFile reads and parses the module file in root (as located by findGoModDir). +func parseGoModFile(root string) (*modfile.File, error) { // The alan_spec build resolves the ssv-spec version from go.spec.alan.mod instead of // go.mod, so the spec-test vectors come from the alan (pre-Boole) spec release. modFileName := specGoModFilename() - root, err := findGoModDir(path) - if err != nil { - return nil, err - } - - // read mod file // #nosec G304 -- modFileName is selected by build tags from fixed constants. buf, err := os.ReadFile(filepath.Join(filepath.Clean(root), modFileName)) if err != nil { return nil, fmt.Errorf("could not read %s", modFileName) } - // parse mod file return modfile.Parse(modFileName, buf, nil) } diff --git a/message/validation/const.go b/message/validation/const.go index 211804c43d..41d1e727de 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -32,16 +32,14 @@ const ( const proposerPreferencesEarlyEpochs = 2 // maxProposerPreferencesDistinctRoots bounds the distinct ProposerPreferences signing roots one -// (slot, signer) may contribute (SIP #94 §5). Unlike other pre-consensus messages (capped at 1), a -// proposer re-emits its preference under a new root when the proposal slot's dependent_root changes, so -// the bound admits a few genuine reorg-driven refreshes while still capping duplicates and flooding. -// The value is shared with the §5 dispatcher's pending stash, hence the central constant. +// (slot, signer) may contribute (SIP #94 §5): unlike other pre-consensus messages (capped at 1), a +// proposer re-emits under a new root when the slot's dependent_root changes. Derivation at the +// shared constant. const maxProposerPreferencesDistinctRoots = gloas.MaxProposerPreferencesDistinctRoots // maxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one (slot, signer) -// may contribute (issue #2962): one root per configured direct-builder entry — auth roots don't -// depend on dependent_root, so unlike §5 preferences a reorg never mints new ones — plus headroom -// for a config change between emissions. Shared with the config entry cap and the dispatcher stash. +// may contribute (issue #2962): exactly one per configured direct-builder entry. Derivation at the +// shared constant. const maxRequestAuthDistinctRoots = gloas.MaxRequestAuthDistinctRoots const ( diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 4c6606e16d..ba46519934 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -322,11 +322,9 @@ func validatePartialSignatureMessageLimit( return e } case spectypes.RequestAuthPartialSig: - // Issue #2962 (§5 request-auth extension): one root per configured direct builder per - // proposal slot, admitted up to maxRequestAuthDistinctRoots distinct roots per (slot, signer) - // with the same two-tier handling as §5 preferences — a same-peer repeat of a seen root is a - // provable duplicate (REJECT), a relayed repeat or over-budget distinct root is rate-limiting - // (IGNORE). + // Issue #2962 (§5 request-auth extension): up to maxRequestAuthDistinctRoots distinct roots + // per (slot, signer) — one per configured builder — with the preference case's two-tier + // handling: same-peer repeat REJECT, relayed repeat or over-budget distinct root IGNORE. root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) if signerState.Peer(receivedFrom).hasRequestAuthRoot(root) { e := ErrTooManyPartialSigMessage diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 40f992341a..90f12686d4 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -76,9 +76,8 @@ type SignerState struct { SeenProposerPreferencesRoots [][32]byte // SeenRequestAuthRoots records the distinct RequestAuthV1 signing roots seen from this signer - // (issue #2962): like §5 preferences the type is capped by distinct root (up to - // maxRequestAuthDistinctRoots — one per configured builder), not by the single pre-consensus - // bit in SeenMsgTypes. nil until the first such message. + // (issue #2962) — root-capped like the §5 preference roots above, up to + // maxRequestAuthDistinctRoots. nil until the first such message. SeenRequestAuthRoots [][32]byte } diff --git a/operator/validator/controller.go b/operator/validator/controller.go index d25d658954..dba44f21a5 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1258,10 +1258,10 @@ func SetupRunners( // §4-decided block root) and the §6 envelope runner (which reads it). proposedBlockRoots := ssv.NewProposedBlockRoots() - // requestAuthCache is shared between this validator's proposer-preferences runner (which writes - // each threshold-reconstructed builder request auth, issue #2962) and the proposer runner's §4 - // produce path (which will attach them once the produceBlockV4 POST migration lands). - requestAuthCache := ssv.NewRequestAuthCache() + // requestAuthCache collects this validator's threshold-reconstructed builder request auths + // (issue #2962), written by the proposer-preferences runner. No reader yet: the proposer + // runner's §4 produce path starts consuming it with the produceBlockV4 POST migration. + requestAuthCache := ssv.NewRequestAuthCache(options.NetworkConfig.EstimatedCurrentSlot) runners := runner.ValidatorDutyRunners{} var err error diff --git a/protocol/v2/ssv/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go index 19e87c32f6..212196e863 100644 --- a/protocol/v2/ssv/request_auth_cache.go +++ b/protocol/v2/ssv/request_auth_cache.go @@ -9,56 +9,55 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// requestAuthRetentionSlots bounds how long reconstructed request auths outlive their proposal -// slot. An auth is written up to the proposer lookahead ahead of its slot and consumed at (or just -// before) it — §4 bid requests and the epoch-prior submitBuilderPreferences — so entries for slots -// this far behind the newest write are dead weight. -const requestAuthRetentionSlots = 4 - -// RequestAuthCache holds, per (validator, proposal slot), the threshold-reconstructed -// SignedRequestAuthV1 for each configured builder relationship (issue #2962 B1), keyed by -// gloas.BuilderIdentity. The §5 slot sub-runners write on reconstruction quorum; the §4 produce -// path (and later the ahead-of-time submitBuilderPreferences) read. It is shared between a single -// validator's runners; lives in package ssv alongside ProposedBlockRoots for the same -// import-cycle reason. Safe for concurrent use. +// RequestAuthCache holds, per proposal slot, the threshold-reconstructed SignedRequestAuthV1 for +// each configured builder relationship (issue #2962 B1), keyed by gloas.BuilderIdentity. The §5 +// slot sub-runners write on reconstruction quorum; the §4 produce path (and later the ahead-of-time +// submitBuilderPreferences) will read. One instance per validator, shared between its runners like +// the sibling ProposedBlockRoots and in package ssv for the same import-cycle reason. Safe for +// concurrent use. type RequestAuthCache struct { + // currentSlot anchors eviction: writes land up to a proposer lookahead ahead of their slot, so + // pruning by the clock — not by the last-written slot — is what keeps one future slot's auths + // from evicting another's. + currentSlot func() phase0.Slot + mu sync.Mutex - auths map[phase0.ValidatorIndex]map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1 + auths map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1 } -func NewRequestAuthCache() *RequestAuthCache { - return &RequestAuthCache{auths: make(map[phase0.ValidatorIndex]map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1)} +func NewRequestAuthCache(currentSlot func() phase0.Slot) *RequestAuthCache { + return &RequestAuthCache{ + currentSlot: currentSlot, + auths: make(map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1), + } } -// Store records the reconstructed auth for the validator's proposal slot under the builder -// identity, and evicts slots more than the retention window behind the newest stored slot. -func (c *RequestAuthCache) Store(validatorIndex phase0.ValidatorIndex, slot phase0.Slot, builderIdentity string, auth *gloas.SignedRequestAuthV1) { +// Store records the reconstructed auth for the proposal slot under the builder identity, and +// evicts slots the chain has moved past. Auths for future proposal slots are always kept — their +// count is bounded by the proposer lookahead times the builder-entry cap. +func (c *RequestAuthCache) Store(slot phase0.Slot, builderIdentity string, auth *gloas.SignedRequestAuthV1) { c.mu.Lock() defer c.mu.Unlock() - bySlot := c.auths[validatorIndex] - if bySlot == nil { - bySlot = make(map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1) - c.auths[validatorIndex] = bySlot - } - byBuilder := bySlot[slot] + byBuilder := c.auths[slot] if byBuilder == nil { byBuilder = make(map[string]*gloas.SignedRequestAuthV1) - bySlot[slot] = byBuilder + c.auths[slot] = byBuilder } byBuilder[builderIdentity] = auth - for sl := range bySlot { - if slot > requestAuthRetentionSlots && sl < slot-requestAuthRetentionSlots { - delete(bySlot, sl) + current := c.currentSlot() + for sl := range c.auths { + if sl < current { + delete(c.auths, sl) } } } -// Get returns a copy of the builder-identity → reconstructed-auth map for the validator's proposal -// slot; empty when nothing reconstructed yet. -func (c *RequestAuthCache) Get(validatorIndex phase0.ValidatorIndex, slot phase0.Slot) map[string]*gloas.SignedRequestAuthV1 { +// Get returns a copy of the builder-identity → reconstructed-auth map for the proposal slot; empty +// when nothing reconstructed yet. +func (c *RequestAuthCache) Get(slot phase0.Slot) map[string]*gloas.SignedRequestAuthV1 { c.mu.Lock() defer c.mu.Unlock() - return maps.Clone(c.auths[validatorIndex][slot]) + return maps.Clone(c.auths[slot]) } diff --git a/protocol/v2/ssv/request_auth_cache_test.go b/protocol/v2/ssv/request_auth_cache_test.go index b936990b6a..ba75008cd5 100644 --- a/protocol/v2/ssv/request_auth_cache_test.go +++ b/protocol/v2/ssv/request_auth_cache_test.go @@ -10,28 +10,33 @@ import ( ) func TestRequestAuthCache(t *testing.T) { - cache := NewRequestAuthCache() + now := phase0.Slot(100) + cache := NewRequestAuthCache(func() phase0.Slot { return now }) authAt := func(slot phase0.Slot) *gloas.SignedRequestAuthV1 { return &gloas.SignedRequestAuthV1{Message: &gloas.RequestAuthV1{Data: []byte("x"), Slot: slot}} } - require.Empty(t, cache.Get(1, 100)) + require.Empty(t, cache.Get(110)) - cache.Store(1, 100, "builder-a", authAt(100)) - cache.Store(1, 100, "builder-b", authAt(100)) - cache.Store(2, 100, "builder-a", authAt(100)) - - require.Len(t, cache.Get(1, 100), 2) - require.Len(t, cache.Get(2, 100), 1) - require.Empty(t, cache.Get(3, 100)) + cache.Store(110, "builder-a", authAt(110)) + cache.Store(110, "builder-b", authAt(110)) + require.Len(t, cache.Get(110), 2) + require.Empty(t, cache.Get(111)) // The returned map is a copy: mutating it must not affect the cache. - got := cache.Get(1, 100) + got := cache.Get(110) delete(got, "builder-a") - require.Len(t, cache.Get(1, 100), 2) - - // Slots more than the retention window behind the newest stored slot are evicted per validator. - cache.Store(1, 100+requestAuthRetentionSlots+1, "builder-a", authAt(100+requestAuthRetentionSlots+1)) - require.Empty(t, cache.Get(1, 100), "stale slot must be evicted") - require.Len(t, cache.Get(2, 100), 1, "other validators' slots are untouched") + require.Len(t, cache.Get(110), 2) + + // Eviction is clock-anchored: writing a much later lookahead slot must NOT evict an earlier + // slot that is still in the future (the §5 lookahead spans two epochs, so a validator can hold + // proposal slots arbitrarily far apart within it). + cache.Store(160, "builder-a", authAt(160)) + require.Len(t, cache.Get(110), 2, "a future slot must survive writes for later slots") + + // Once the chain moves past a slot, the next write prunes it; future slots stay. + now = 111 + cache.Store(160, "builder-b", authAt(160)) + require.Empty(t, cache.Get(110), "a past slot must be evicted") + require.Len(t, cache.Get(160), 2) } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 411db9e852..41cade515f 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -52,10 +52,9 @@ type ProposerPreferencesRunner struct { pending map[phase0.Slot][]*spectypes.PartialSignatureMessages } -// maxPendingRootsPerSigner is how many stashed partials one signer can legitimately account for per -// proposal slot: the wire admits at most gloas.MaxProposerPreferencesDistinctRoots distinct §5 -// preference roots plus gloas.MaxRequestAuthDistinctRoots distinct request-auth roots per -// (slot, signer) — the same shared constants message validation enforces. +// maxPendingRootsPerSigner caps stashed partials per (slot, signer): the wire admits at most this +// many distinct §5-role signing roots — the preference budget plus the request-auth budget, the +// same shared constants message validation enforces. const maxPendingRootsPerSigner = gloas.MaxProposerPreferencesDistinctRoots + gloas.MaxRequestAuthDistinctRoots // ProposerPreferencesRunnerOptions bundles the dependencies required by NewProposerPreferencesRunner. @@ -69,8 +68,7 @@ type ProposerPreferencesRunnerOptions struct { // authenticatable entry the slot sub-runners additionally threshold-sign a RequestAuthV1 per // upcoming proposal slot. Empty (the default) disables the overlay entirely. Builders []gloas.BuilderEntry - // RequestAuthCache receives each reconstructed SignedRequestAuthV1, shared with the validator's - // proposer runner so the §4 produce path can attach auths at proposal time. + // RequestAuthCache receives each reconstructed SignedRequestAuthV1 for the §4 produce path. RequestAuthCache *ssv.RequestAuthCache } @@ -109,8 +107,7 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za if prev, ok := r.bySlot[slot]; ok { sub.submittedPreferences = prev.submittedPreferences sub.broadcastPreferences = prev.broadcastPreferences - // Auth roots are re-emission-invariant: never re-broadcast one already out, never redo a - // reconstruction already cached. + // The auth markers carry over too — roots are re-emission-invariant (see the field docs). sub.broadcastAuthRoots = prev.broadcastAuthRoots sub.reconstructedAuthRoots = prev.reconstructedAuthRoots if prev.hasDutyRunning() { @@ -332,32 +329,35 @@ type proposerPreferencesSlotRunner struct { builders []gloas.BuilderEntry requestAuthCache *ssv.RequestAuthCache - // requestAuths maps each frozen RequestAuthV1's signing root to the object and its builder, set - // when the duty executes; incoming RequestAuthPartialSig messages are admitted only against - // these roots. nil means the duty has not executed here yet. + // requestAuths maps each frozen RequestAuthV1's signing root to the object and its builders; + // incoming RequestAuthPartialSig messages are admitted only against these roots. nil until the + // duty executes here. requestAuths map[[32]byte]*frozenRequestAuth - // requestAuthContainer collects request-auth partials separately from the §5 preference round: - // the two sign under different domains, so they cannot share the base pre-consensus round, and - // auth collection legitimately keeps running after the preference round concludes the duty. + // requestAuthContainer collects request-auth partials separately from the preference round: + // different signing domains cannot share the base pre-consensus round, and auth collection must + // keep running after the preference concludes the duty. requestAuthContainer *ssv.PartialSigContainer - // broadcastAuthRoots records the auth roots this operator already broadcast a partial for, - // carried across sub-runner replacements like broadcastPreferences: auth roots don't depend on - // dependent_root, so a re-emission re-produces the identical root and a re-broadcast would only - // get this operator gossip-penalized as a same-peer duplicate (issue #2934). - broadcastAuthRoots map[[32]byte]struct{} - - // reconstructedAuthRoots records the auth roots already reconstructed into the cache, carried - // across sub-runner replacements like broadcastAuthRoots: a replacement's fresh container would - // otherwise re-reach quorum from the stash replay and redo the reconstruction (and its metric) - // for a value that cannot have changed. + // broadcastAuthRoots and reconstructedAuthRoots carry across sub-runner replacements like + // broadcastPreferences: auth roots are re-emission-invariant, so a replacement must neither + // re-broadcast a root already out (a same-peer duplicate, issue #2934) nor redo a + // reconstruction its stash replay would re-reach quorum for. + broadcastAuthRoots map[[32]byte]struct{} reconstructedAuthRoots map[[32]byte]struct{} } -// frozenRequestAuth pairs a frozen RequestAuthV1 with the builder relationship it authenticates. +// frozenRequestAuth pairs a frozen RequestAuthV1 with every configured builder relationship it +// authenticates: the signing root derives from (data, slot) alone — not the URL — so distinct +// entries sharing one pre-agreed token converge on one root, one broadcast, and one reconstruction +// serving them all. type frozenRequestAuth struct { auth *gloas.RequestAuthV1 + builders []frozenBuilderRef +} + +// frozenBuilderRef names one configured builder relationship covered by a frozen auth. +type frozenBuilderRef struct { identity string // gloas.BuilderIdentity — the RequestAuthCache key url string // for logging } @@ -482,8 +482,7 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger proposalSlot := validatorDuty.DutySlot() // The request-auth round rides every execution of the §5 duty, ahead of the preference logic so - // none of its early-return paths (unchanged preference, in-flight broadcast) can skip it. It - // never fails the duty: the overlay is opt-in and the §5 preference must not depend on it. + // none of its early-return paths can skip it; it never fails the duty. r.runRequestAuthRound(ctx, logger, validatorDuty, proposalSlot) preferences, err := r.buildProposerPreferences(ctx, proposalSlot) @@ -546,11 +545,10 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger } // runRequestAuthRound freezes one RequestAuthV1{data, proposal_slot} per authenticatable configured -// builder (issue #2962 B1): records each auth's signing root so incoming partials can be admitted, -// and signs and broadcasts this operator's partial — once per root, across re-emissions (auth roots -// are re-emission-invariant, see broadcastAuthRoots). Per-builder failures are logged and skipped, -// never failing the §5 duty: a builder whose auth misses quorum is simply not contactable for the -// slot, and the enshrined flow (gossip bids, self-build) stays available. +// builder, records its signing root so incoming partials can be admitted, and broadcasts this +// operator's partial — once per root, across re-emissions. Per-builder failures are logged and +// skipped, never failing the §5 duty: a builder whose auth misses quorum is simply not contactable +// for the slot, and the enshrined flow (gossip bids, self-build) stays available. func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, logger *zap.Logger, validatorDuty *spectypes.ValidatorDuty, proposalSlot phase0.Slot) { if len(r.builders) == 0 { return @@ -564,16 +562,12 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, return } - if r.requestAuths == nil { - r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) - } + r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) for i := range r.builders { entry := &r.builders[i] data, err := entry.AuthDataBytes() if err != nil || len(data) == 0 { - // The default (empty-URL) entry is not authenticatable, and invalid auth data is - // rejected at startup — nothing to sign either way. - continue + continue // the default (empty-URL) entry has nothing to authenticate; invalid data is rejected at startup } auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} root, err := spectypes.ComputeETHSigningRoot(auth, domain) @@ -582,13 +576,16 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) continue } - r.requestAuths[root] = &frozenRequestAuth{auth: auth, identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} + ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} + if frozen, ok := r.requestAuths[root]; ok { + // Another entry froze these exact bytes; register the extra relationship on the shared root. + frozen.builders = append(frozen.builders, ref) + continue + } + r.requestAuths[root] = &frozenRequestAuth{auth: auth, builders: []frozenBuilderRef{ref}} if _, done := r.broadcastAuthRoots[root]; done { - // A prior incarnation of this slot already broadcast this exact auth; the dispatcher's - // stash replay and live partials complete its quorum, a re-broadcast would only be - // dropped as a same-peer duplicate. - continue + continue // broadcast by a prior incarnation; stash replay and live partials finish its quorum } msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainRequestAuth), domain) if err != nil { @@ -612,9 +609,8 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, // processRequestAuthPartial collects request-auth partials into their own container and, on the // first quorum for a root, reconstructs the builder-facing SignedRequestAuthV1 into the shared -// cache. Unlike the §5 preference round it has no succeeded-gate: the preference submission -// concluding the duty must not stop auth collection, which legitimately continues until the -// proposal slot (the sub-runner lingers until evicted). +// cache. No succeeded-gate: the preference submission concluding the duty must not stop auth +// collection, which legitimately runs until the sub-runner is evicted. func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { if !r.hasDutyAssigned() { return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) @@ -622,6 +618,11 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co if err := r.validatePartialSigMsg(signedMsg, r.State.CurrentDuty.DutySlot()); err != nil { return fmt.Errorf("invalid request-auth partial: %w", err) } + // The auth root, unlike the §5 preference, doesn't bind the validator index — tie the message + // to this runner's share explicitly, as the post-consensus paths do. + if err := r.validateValidatorIndexInPartialSigMsg(signedMsg); err != nil { + return err + } if len(signedMsg.Messages) != 1 { return errors.New("request-auth partial must carry exactly one message") } @@ -634,15 +635,12 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co } frozen, ok := r.requestAuths[msg.SigningRoot] if !ok { - // A root we didn't freeze: the sender's builder list or auth-data bytes diverge from ours; - // whatever quorum it can reach forms on the operators that share its config. + // The sender's builder list or auth-data bytes diverge from ours; whatever quorum this root + // can reach forms on the operators that share its config. return fmt.Errorf("unknown request-auth signing root %x", msg.SigningRoot) } if _, done := r.reconstructedAuthRoots[msg.SigningRoot]; done { - // Already reconstructed and cached, possibly by a prior incarnation of this slot — the - // carried marker keeps a replacement's stash replay from redoing the work; late partials - // add nothing. - return nil + return nil // reconstructed and cached, possibly by a prior incarnation; late partials add nothing } // quorum returns true only once per root (the first time it is reached). @@ -661,13 +659,17 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co copy(signature[:], fullSig) r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} - if r.requestAuthCache != nil { - r.requestAuthCache.Store(r.GetShare().ValidatorIndex, frozen.auth.Slot, frozen.identity, - &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature}) + signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} + urls := make([]string, 0, len(frozen.builders)) + for _, ref := range frozen.builders { + if r.requestAuthCache != nil { + r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) + } + urls = append(urls, ref.url) } recordRequestAuthReconstruction(ctx) logger.Info("✔️ reconstructed builder request auth", - fields.Slot(frozen.auth.Slot), zap.String("builder_url", frozen.url)) + fields.Slot(frozen.auth.Slot), zap.Strings("builder_urls", urls)) return nil } diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index 700bf9f966..46ad137c1c 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -43,11 +43,12 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) - cache := ssv.NewRequestAuthCache() + cache := ssv.NewRequestAuthCache(cfg.EstimatedCurrentSlot) builders := []gloas.BuilderEntry{ {URL: "https://builder-a.example.com"}, // auth data defaults to the URL bytes {URL: "https://builder-b.example.com", AuthData: "0x010203"}, // explicit pre-agreed bytes + {URL: "https://builder-d.example.com", AuthData: "0x010203"}, // distinct builder sharing B's token {}, // the default entry: unsigned preferences only, no auth round } require.NoError(t, gloas.ValidateBuilderEntries(builders)) @@ -110,14 +111,15 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { require.True(t, IsRetryable(err)) } - // Our emission: one preference partial plus one auth partial per authenticatable builder go out, - // the stash replays builder A's peer partials to quorum, and its auth lands in the cache. + // Our emission: one preference partial plus one auth partial per distinct auth root goes out — + // builders B and D share a token, so they share a root and a single broadcast; the stash + // replays builder A's peer partials to quorum, and its auth lands in the cache. require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) types := broadcastPartialSigTypes(t, network.BroadcastedMsgs) require.Equal(t, 1, types[spectypes.ProposerPreferencesPartialSig]) - require.Equal(t, 2, types[spectypes.RequestAuthPartialSig], "one auth partial per authenticatable builder; the default entry signs nothing") + require.Equal(t, 2, types[spectypes.RequestAuthPartialSig], "one auth partial per distinct root; the token-sharing pair broadcasts once, the default entry signs nothing") - auths := cache.Get(share.ValidatorIndex, proposalSlot) + auths := cache.Get(proposalSlot) require.Len(t, auths, 1, "builder A reached quorum via stash replay") authA := auths[gloas.BuilderIdentity("https://builder-a.example.com", builderAData)] require.NotNil(t, authA) @@ -128,13 +130,16 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { // submit yet — its quorum is driven separately below to prove full independence). require.Empty(t, bn.submitted) - // Builder B's peer partials arrive live; its auth reconstructs too. + // The shared-token root's peer partials arrive live; ONE reconstruction must serve BOTH builder + // relationships that agreed on those bytes (the root derives from (data, slot), not the URL). for _, op := range []spectypes.OperatorID{2, 3, 4} { require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, op, builderBData))) } - auths = cache.Get(share.ValidatorIndex, proposalSlot) - require.Len(t, auths, 2) + auths = cache.Get(proposalSlot) + require.Len(t, auths, 3) require.NotNil(t, auths[gloas.BuilderIdentity("https://builder-b.example.com", builderBData)]) + require.NotNil(t, auths[gloas.BuilderIdentity("https://builder-d.example.com", builderBData)], + "a builder sharing another's token must get its own cache entry from the shared reconstruction") // A re-emission for the same slot re-freezes but never re-broadcasts an auth (roots are // re-emission-invariant and already out) — only the §5 side decides re-broadcast on its own rules. @@ -162,7 +167,7 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) - cache := ssv.NewRequestAuthCache() + cache := ssv.NewRequestAuthCache(cfg.EstimatedCurrentSlot) builders := []gloas.BuilderEntry{{URL: "https://builder-a.example.com"}} runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ @@ -244,6 +249,6 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. }}, })) } - require.Len(t, cache.Get(share.ValidatorIndex, proposalSlot), 1, + require.Len(t, cache.Get(proposalSlot), 1, "auth must reconstruct even after the §5 preference concluded the duty") } diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 36555313ef..70698b0c58 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -7,17 +7,17 @@ import ( "strings" ) -// MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2). It bounds both the -// operator config and, on the wire, the distinct RequestAuthV1 signing roots message validation -// admits per (proposal slot, signer) — the two must stay in step, so both reference this constant. +// MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2) and, through +// MaxRequestAuthDistinctRoots, the wire budget that config implies. const MaxBuilderEntries = 8 // MaxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one signer may put on -// the wire per proposal slot (issue #2962 B1): one root per authenticatable builder entry, plus -// headroom for a config change between emissions (roots are dependent_root-independent, so unlike -// §5 preferences a reorg re-emission never mints a new one). Message validation enforces it -// world-wide per (slot, signer); the §5 dispatcher sizes its pending stash from it. -const MaxRequestAuthDistinctRoots = MaxBuilderEntries + 4 +// the wire per proposal slot: exactly one per authenticatable builder entry, so the budget equals +// the entry cap. No headroom is warranted — auth roots don't move with dependent_root and the +// builder list is read once at startup, while wire validation is config-independent, so every +// extra admitted root would burden clusters that never opt in. Message validation enforces it per +// (slot, signer); the §5 dispatcher sizes its pending stash from it. +const MaxRequestAuthDistinctRoots = MaxBuilderEntries // BuilderIdentity is the identity of a configured builder relationship: the (URL, auth data) pair, // per keymanager-APIs#87 (multiple entries MAY share a URL with different auth data). It keys the @@ -27,18 +27,16 @@ func BuilderIdentity(url string, authData []byte) string { } // BuilderEntry is one configured direct builder for the ePBS (Gloas) external-builder overlay -// (issue #2962): the opt-in, off-protocol path that authenticates the cluster to a builder and -// carries per-builder bid preferences. Field vocabulary follows keymanager-APIs#87's BuilderEntry -// (plus beacon-APIs#625's max_trusted_bid), so SSV config reads like the rest of the ecosystem. +// (issue #2962), in keymanager-APIs#87's field vocabulary plus beacon-APIs#625's max_trusted_bid. // -// Entries MUST be configured identically across ALL operators of every cluster sharing a validator: -// AuthData is threshold-signed into RequestAuthV1 (any byte divergence splits the quorum and -// silently disables that builder), and the unsigned knobs steer bid selection per-operator (their -// divergence is consensus-safe but makes the effective policy "whoever leads the round"). See +// Entries MUST be identical across ALL operators of every cluster sharing a validator: AuthData is +// threshold-signed into RequestAuthV1, so any byte divergence splits the quorum and silently +// disables that builder; the unsigned knobs steer bid selection per-operator, where divergence is +// consensus-safe but leaves the effective policy to whoever leads the round. See // docs/EXTERNAL_BUILDERS.md. // -// Only URL/AuthData are consumed pre-signing (phase 1); the unsigned knobs take effect with the -// produceBlockV4 POST migration and the semantics track beacon-APIs#625 until it merges. +// Today only URL and AuthData are consumed; the unsigned knobs take effect with the produceBlockV4 +// POST migration and track beacon-APIs#625 until it merges. type BuilderEntry struct { // URL the beacon node (and, for submitBuilderPreferences, the SSV node) contacts the builder // on. An empty URL denotes the single default entry: unsigned preferences applied to any @@ -101,10 +99,10 @@ func (e *BuilderEntry) EffectiveBoostFactor() uint64 { // ValidateBuilderEntries checks a configured builder list: entry cap, at most one default // (empty-URL) entry carrying no AuthData, parseable http(s) URLs, decodable within-limit auth -// data, no duplicate (URL, auth data) identities (the keymanager-APIs#87 entry identity — multiple -// entries MAY share a URL with different auth data), and well-formed optional pubkeys. It cannot -// check the one property that matters most — that every operator of every shared cluster holds the -// identical list — which stays an operational requirement (docs/EXTERNAL_BUILDERS.md). +// data, no duplicate (URL, auth data) identities (multiple entries MAY share a URL with different +// auth data), and well-formed optional pubkeys. The property that matters most — every operator of +// every shared cluster holding the identical list — cannot be checked here and stays an +// operational requirement (docs/EXTERNAL_BUILDERS.md). func ValidateBuilderEntries(entries []BuilderEntry) error { if len(entries) > MaxBuilderEntries { return fmt.Errorf("%d builder entries exceed the %d limit", len(entries), MaxBuilderEntries) diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index 1b4904efe3..410a016f62 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -1,6 +1,7 @@ package gloas import ( + "encoding/hex" "strings" "testing" @@ -77,7 +78,7 @@ func TestValidateBuilderEntries(t *testing.T) { require.ErrorContains(t, ValidateBuilderEntries([]BuilderEntry{ {URL: "https://x.example"}, - {URL: "https://x.example", AuthData: "0x" + hexOf("https://x.example")}, + {URL: "https://x.example", AuthData: "0x" + hex.EncodeToString([]byte("https://x.example"))}, }), "duplicate") require.ErrorContains(t, @@ -89,12 +90,3 @@ func TestValidateBuilderEntries(t *testing.T) { require.NoError(t, ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x" + strings.Repeat("ab", 48)}})) } - -func hexOf(s string) string { - const digits = "0123456789abcdef" - out := make([]byte, 0, len(s)*2) - for i := 0; i < len(s); i++ { - out = append(out, digits[s[i]>>4], digits[s[i]&0x0f]) - } - return string(out) -} From 26996593fcab5d5ddc1c813cedbc329bdf125a59 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 29 Jul 2026 10:09:39 +0300 Subject: [PATCH 124/150] =?UTF-8?q?gloas:=20#2962=20follow-ups=20=E2=80=94?= =?UTF-8?q?=20hard-fail=20auth=20partials=20without=20local=20builders;=20?= =?UTF-8?q?contract=20and=20metric=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node with no local builder entries (never configured, or disabled for a remote signer) never freezes auth roots, so peer request-auth partials for a started duty now fail hard instead of exhausting the retry budget — retrying could never help there; the duty-start race keeps its retryable path. Regression-tested. Wording follow-ups: the cache documents its shallow-copy contract (returned auths are shared across the cache, the runner's frozen state, and token-sharing identities — treat as immutable) and no longer implies a present-day reader; the reconstruction metric states its root granularity (token-sharing builders count once, unit {root}); the budget comment owns the restart-with-a-changed-list case as an accepted, self-healing cost; and the loop-invariant cache nil-check is hoisted. --- protocol/v2/ssv/request_auth_cache.go | 11 ++-- protocol/v2/ssv/runner/observability.go | 11 ++-- .../v2/ssv/runner/proposer_preferences.go | 17 ++++-- protocol/v2/ssv/runner/request_auth_test.go | 53 +++++++++++++++++++ protocol/v2/types/gloas/builder_entry.go | 10 ++-- 5 files changed, 83 insertions(+), 19 deletions(-) diff --git a/protocol/v2/ssv/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go index 212196e863..c2b2d06f10 100644 --- a/protocol/v2/ssv/request_auth_cache.go +++ b/protocol/v2/ssv/request_auth_cache.go @@ -11,10 +11,10 @@ import ( // RequestAuthCache holds, per proposal slot, the threshold-reconstructed SignedRequestAuthV1 for // each configured builder relationship (issue #2962 B1), keyed by gloas.BuilderIdentity. The §5 -// slot sub-runners write on reconstruction quorum; the §4 produce path (and later the ahead-of-time -// submitBuilderPreferences) will read. One instance per validator, shared between its runners like -// the sibling ProposedBlockRoots and in package ssv for the same import-cycle reason. Safe for -// concurrent use. +// slot sub-runners write on reconstruction quorum; there is no reader yet — the §4 produce path +// (and later the ahead-of-time submitBuilderPreferences) becomes one with the produceBlockV4 POST +// migration. One instance per validator, in package ssv beside ProposedBlockRoots for the same +// import-cycle reason. Safe for concurrent use. type RequestAuthCache struct { // currentSlot anchors eviction: writes land up to a proposer lookahead ahead of their slot, so // pruning by the clock — not by the last-written slot — is what keeps one future slot's auths @@ -55,7 +55,8 @@ func (c *RequestAuthCache) Store(slot phase0.Slot, builderIdentity string, auth } // Get returns a copy of the builder-identity → reconstructed-auth map for the proposal slot; empty -// when nothing reconstructed yet. +// when nothing reconstructed yet. The copy is shallow: the auths are shared with the cache, with +// the runner's frozen state, and across token-sharing identities — treat them as immutable. func (c *RequestAuthCache) Get(slot phase0.Slot) map[string]*gloas.SignedRequestAuthV1 { c.mu.Lock() defer c.mu.Unlock() diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 4509365de7..56dc1e498b 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -140,8 +140,8 @@ var ( requestAuthReconstructionCounter = metrics.New( meter.Int64Counter( observability.InstrumentName(observabilityNamespace, "request_auth.reconstructions"), - metric.WithUnit("{auth}"), - metric.WithDescription("threshold-reconstructed Gloas direct-builder request auths (issue #2962)"))) + metric.WithUnit("{root}"), + metric.WithDescription("threshold-reconstructed Gloas direct-builder request-auth signing roots (issue #2962); token-sharing builders share a root and count once"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -195,9 +195,10 @@ func recordEnvelopeBuildMatch(ctx context.Context, self bool) { envelopeBuildMatchCounter.Add(ctx, 1, metric.WithAttributes(observability.EnvelopeBuildMatchAttribute(match))) } -// recordRequestAuthReconstruction counts a threshold-reconstructed direct-builder request auth -// (issue #2962). The inverse signal — an auth that never reached quorum — is measured where it -// bites: at the §4 produce path's cache lookup, once the produce-POST migration lands. +// recordRequestAuthReconstruction counts a threshold-reconstructed request-auth signing root +// (issue #2962; token-sharing builders share a root and count once). The inverse signal — an auth +// that never reached quorum — is measured where it bites: at the §4 produce path's cache lookup, +// once the produce-POST migration lands. func recordRequestAuthReconstruction(ctx context.Context) { requestAuthReconstructionCounter.Add(ctx, 1) } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 41cade515f..d7b6e459c7 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -629,8 +629,13 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co msg := signedMsg.Messages[0] if r.requestAuths == nil { - // Duty assigned but not executed here yet (or no builders configured): retryable, so a - // partial racing the duty start also lands via the queue replay and the dispatcher stash. + if len(r.builders) == 0 { + // No overlay here (never configured, or disabled for a remote signer): no root will + // ever be frozen for this slot, so retrying cannot help. + return errors.New("no builders configured") + } + // Duty assigned but not executed here yet: retryable, so a partial racing the duty start + // also lands via the queue replay and the dispatcher stash. return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, errors.New("no frozen request auths"))) } frozen, ok := r.requestAuths[msg.SigningRoot] @@ -659,13 +664,15 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co copy(signature[:], fullSig) r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} - signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} urls := make([]string, 0, len(frozen.builders)) for _, ref := range frozen.builders { - if r.requestAuthCache != nil { + urls = append(urls, ref.url) + } + if r.requestAuthCache != nil { + signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} + for _, ref := range frozen.builders { r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) } - urls = append(urls, ref.url) } recordRequestAuthReconstruction(ctx) logger.Info("✔️ reconstructed builder request auth", diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index 46ad137c1c..10be5ee004 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -154,6 +154,59 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { require.False(t, IsRetryable(err)) } +// A node without builders (never configured, or disabled for a remote signer) never freezes auth +// roots, so peer auth partials for a started duty must fail hard — retrying cannot help. Only a +// partial racing the duty start stays retryable. +func TestProposerPreferencesRunner_requestAuthWithoutBuilders(t *testing.T) { + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + cfg := cloneTestNetworkConfig() + + bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} + runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]), + Signer: ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()), + OperatorSigner: spectestingutils.NewOperatorSigner(keySet, 1), + }, + FeeRecipientProvider: fixedFeeRecipientProvider{addr: bellatrix.ExecutionAddress{0xfe}}, + GasLimit: 36_000_000, + }) + require.NoError(t, err) + disp := runnerIface.(*ProposerPreferencesRunner) + + proposalSlot := cfg.EstimatedCurrentSlot() + 5 + require.NoError(t, disp.StartNewDuty(context.Background(), zap.NewNop(), &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + }, 3)) + + auth := &gloas.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: proposalSlot} + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + require.NoError(t, err) + sig := keySet.Shares[2].SignByte(root[:]) + + err = disp.ProcessPreConsensus(context.Background(), zap.NewNop(), &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: 2, + ValidatorIndex: share.ValidatorIndex, + }}, + }) + require.ErrorContains(t, err, "no builders configured") + require.False(t, IsRetryable(err), "no root can ever be frozen here, so retrying cannot help") +} + // The request-auth round must not disturb the §5 preference lifecycle: with builders configured, // the preference still submits on its own quorum, and auth collection keeps running after the §5 // duty has already succeeded (no succeeded-gate on the auth path). diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 70698b0c58..1a85ed1f75 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -13,10 +13,12 @@ const MaxBuilderEntries = 8 // MaxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one signer may put on // the wire per proposal slot: exactly one per authenticatable builder entry, so the budget equals -// the entry cap. No headroom is warranted — auth roots don't move with dependent_root and the -// builder list is read once at startup, while wire validation is config-independent, so every -// extra admitted root would burden clusters that never opt in. Message validation enforces it per -// (slot, signer); the §5 dispatcher sizes its pending stash from it. +// the entry cap. No headroom is provisioned — auth roots don't move with dependent_root, and wire +// validation is config-independent, so every extra admitted root would burden clusters that never +// opt in. The accepted cost: a restart with a changed builder list can present fresh roots to a +// slot whose budget is already spent, and the excess is IGNOREd until that slot passes +// (self-healing as the lookahead rolls). Message validation enforces the bound per (slot, signer); +// the §5 dispatcher sizes its pending stash from it. const MaxRequestAuthDistinctRoots = MaxBuilderEntries // BuilderIdentity is the identity of a configured builder relationship: the (URL, auth data) pair, From f43435fe9b531ea4749fe3e69365c830a964ea19 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 29 Jul 2026 15:12:46 +0300 Subject: [PATCH 125/150] =?UTF-8?q?gloas:=20#2962=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20replay=20stash=20into=20concluded=20re-emissions;?= =?UTF-8?q?=20hard-fail=20on=20frozen-nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher's stash replay now gates on duty-ASSIGNED rather than running: an unchanged-preference re-emission concludes not-required immediately (State.Succeeded), which used to skip the replay and leave the replacement's fresh request-auth container permanently short of quorum — peers broadcast their partials exactly once. Preference partials replayed into a concluded duty bounce off the succeeded-gate harmlessly; the auth rounds have no such gate by design. Regression-tested (the new test fails on the old gate). A domain-fetch failure now freezes an empty root set, so peer auth partials hard-fail as unknown roots instead of burning queue retries — the dispatcher stash keeps them for replay should a re-emission freeze successfully. Also: a real AuthDataBytes error is logged instead of silently treated as the default entry; the URL-length check applies only when the URL's bytes are the auth data; the in-memory broadcast markers' restart re-broadcast cost is TODO'd for devnet gauging; message validation's per-type seen-root sets collapse into one seenRootSet type with a shared budget check; and the request-auth round moves to its own file beside the §5 dispatcher. --- message/validation/partial_validation.go | 80 ++++---- .../validation/proposer_preferences_test.go | 26 +-- message/validation/request_auth_test.go | 36 ++-- message/validation/signer_state.go | 51 +++-- .../v2/ssv/runner/proposer_preferences.go | 171 ++--------------- .../proposer_preferences_request_auth.go | 178 ++++++++++++++++++ protocol/v2/ssv/runner/request_auth_test.go | 113 +++++++++++ protocol/v2/types/gloas/builder_entry.go | 3 +- protocol/v2/types/gloas/builder_entry_test.go | 8 + 9 files changed, 406 insertions(+), 260 deletions(-) create mode 100644 protocol/v2/ssv/runner/proposer_preferences_request_auth.go diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index ba46519934..5c69eebdb4 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -301,43 +301,12 @@ func validatePartialSignatureMessageLimit( return e } case spectypes.ProposerPreferencesPartialSig: - // SIP #94 §5: admit up to maxProposerPreferencesDistinctRoots distinct signing roots per - // (slot, signer) — a dependent_root refresh re-emits under a new root — instead of the usual ≤1 - // pre-consensus cap. Only a same-peer repeat of a seen root is a provable duplicate (REJECT); a - // relayed repeat or a distinct root beyond the cap is rate-limiting, not a provable violation (IGNORE). - root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) - if signerState.Peer(receivedFrom).hasProposerPreferencesRoot(root) { - // Same peer re-sent a root it already sent — a logical duplicate; reject to punish. - e := ErrTooManyPartialSigMessage - e.reject = true - e.got = "proposer-preferences, duplicate signing root from peer" - return e - } - if signerState.World.hasProposerPreferencesRoot(root) || - signerState.World.proposerPreferencesRootCount() >= maxProposerPreferencesDistinctRoots { - // A different peer already supplied this root, or the cluster-wide distinct-root budget is - // spent — ignore either way; both are expected under gossip and neither is a provable violation. - e := ErrTooManyPartialSigMessage - e.got = fmt.Sprintf("proposer-preferences, %d distinct root(s) world-wide", signerState.World.proposerPreferencesRootCount()) - return e - } + // SIP #94 §5: a dependent_root refresh re-emits under a new root, so the type is budgeted by + // distinct signing root instead of the usual ≤1 pre-consensus cap. + return validateDistinctRootBudget(m, receivedFrom, signerState, "proposer-preferences", maxProposerPreferencesDistinctRoots) case spectypes.RequestAuthPartialSig: - // Issue #2962 (§5 request-auth extension): up to maxRequestAuthDistinctRoots distinct roots - // per (slot, signer) — one per configured builder — with the preference case's two-tier - // handling: same-peer repeat REJECT, relayed repeat or over-budget distinct root IGNORE. - root := m.Messages[0].SigningRoot // exactly one message for this role (enforced by semantics + count rules) - if signerState.Peer(receivedFrom).hasRequestAuthRoot(root) { - e := ErrTooManyPartialSigMessage - e.reject = true - e.got = "request-auth, duplicate signing root from peer" - return e - } - if signerState.World.hasRequestAuthRoot(root) || - signerState.World.requestAuthRootCount() >= maxRequestAuthDistinctRoots { - e := ErrTooManyPartialSigMessage - e.got = fmt.Sprintf("request-auth, %d distinct root(s) world-wide", signerState.World.requestAuthRootCount()) - return e - } + // Issue #2962 (§5 request-auth extension): one root per configured builder, same budget scheme. + return validateDistinctRootBudget(m, receivedFrom, signerState, "request-auth", maxRequestAuthDistinctRoots) case spectypes.PostConsensusPartialSig: if signerState.Peer(receivedFrom).SeenMsgTypes.reachedPostConsensusLimit() { // Check if the same peer is sending us a "logical duplicate" message, reject message to punish. @@ -360,6 +329,33 @@ func validatePartialSignatureMessageLimit( return nil } +// validateDistinctRootBudget applies the shared dedup for root-budgeted types (§5 preferences and +// #2962 request auths): only a same-peer repeat of a seen root is a provable duplicate (REJECT); a +// relayed repeat, or a distinct root beyond the budget, is rate-limiting, not a provable violation +// (IGNORE). +func validateDistinctRootBudget( + m *spectypes.PartialSignatureMessages, + receivedFrom peer.ID, + signerState *SignerStateForSlotRound, + label string, + budget int, +) error { + root := m.Messages[0].SigningRoot // exactly one message for these types (enforced by semantics + count rules) + if seenRootsFor(signerState.Peer(receivedFrom), m.Type).has(root) { + e := ErrTooManyPartialSigMessage + e.reject = true + e.got = label + ", duplicate signing root from peer" + return e + } + world := seenRootsFor(&signerState.World, m.Type) + if world.has(root) || len(*world) >= budget { + e := ErrTooManyPartialSigMessage + e.got = fmt.Sprintf("%s, %d distinct root(s) world-wide", label, len(*world)) + return e + } + return nil +} + func (mv *messageValidator) updatePartialSignatureState( partialSignatureMessages *spectypes.PartialSignatureMessages, receivedFrom peer.ID, @@ -391,15 +387,11 @@ func (mv *messageValidator) updatePartialSignatureState( // legitimate re-emission — a dependent_root refresh for preferences, another configured builder // for request auths — is admitted up to its bound (see validatePartialSignatureMessageLimit). // Exactly one signature for these types (validated earlier), so Messages[0] holds the root. - switch partialSignatureMessages.Type { - case spectypes.ProposerPreferencesPartialSig: - root := partialSignatureMessages.Messages[0].SigningRoot - signerState.Peer(receivedFrom).recordProposerPreferencesRoot(root) - signerState.World.recordProposerPreferencesRoot(root) - case spectypes.RequestAuthPartialSig: + switch t := partialSignatureMessages.Type; t { + case spectypes.ProposerPreferencesPartialSig, spectypes.RequestAuthPartialSig: root := partialSignatureMessages.Messages[0].SigningRoot - signerState.Peer(receivedFrom).recordRequestAuthRoot(root) - signerState.World.recordRequestAuthRoot(root) + seenRootsFor(signerState.Peer(receivedFrom), t).record(root) + seenRootsFor(&signerState.World, t).record(root) default: // Every other type is capped by the SeenMsgTypes bits recorded above, not by root. } diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index 16bedd99b0..0cbbe6f465 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -173,26 +173,26 @@ func TestValidateBeaconDuty_ProposerPreferencesRequiresAssignment(t *testing.T) } // SignerState tracks distinct ProposerPreferences signing roots (SIP #94 §5): recording is idempotent -// per root, and has/count reflect the distinct set. +// per root, and the set reflects the distinct roots. func TestSignerState_ProposerPreferencesRoots(t *testing.T) { s := &SignerState{} r1 := [32]byte{1} r2 := [32]byte{2} - require.Equal(t, 0, s.proposerPreferencesRootCount()) - require.False(t, s.hasProposerPreferencesRoot(r1)) + require.Empty(t, s.SeenProposerPreferencesRoots) + require.False(t, s.SeenProposerPreferencesRoots.has(r1)) - s.recordProposerPreferencesRoot(r1) - require.True(t, s.hasProposerPreferencesRoot(r1)) - require.Equal(t, 1, s.proposerPreferencesRootCount()) + s.SeenProposerPreferencesRoots.record(r1) + require.True(t, s.SeenProposerPreferencesRoots.has(r1)) + require.Len(t, s.SeenProposerPreferencesRoots, 1) // Recording an already-seen root is a no-op. - s.recordProposerPreferencesRoot(r1) - require.Equal(t, 1, s.proposerPreferencesRootCount()) + s.SeenProposerPreferencesRoots.record(r1) + require.Len(t, s.SeenProposerPreferencesRoots, 1) - s.recordProposerPreferencesRoot(r2) - require.True(t, s.hasProposerPreferencesRoot(r2)) - require.Equal(t, 2, s.proposerPreferencesRootCount()) + s.SeenProposerPreferencesRoots.record(r2) + require.True(t, s.SeenProposerPreferencesRoots.has(r2)) + require.Len(t, s.SeenProposerPreferencesRoots, 2) } // ProposerPreferences pre-consensus admits up to maxProposerPreferencesDistinctRoots distinct signing @@ -207,8 +207,8 @@ func TestValidatePartialSignatureMessageLimit_ProposerPreferences(t *testing.T) } } record := func(ss *SignerStateForSlotRound, from peer.ID, root [32]byte) { - ss.Peer(from).recordProposerPreferencesRoot(root) - ss.World.recordProposerPreferencesRoot(root) + ss.Peer(from).SeenProposerPreferencesRoots.record(root) + ss.World.SeenProposerPreferencesRoots.record(root) } root := func(b byte) [32]byte { return [32]byte{b} } diff --git a/message/validation/request_auth_test.go b/message/validation/request_auth_test.go index f0e382d27f..245038d8bf 100644 --- a/message/validation/request_auth_test.go +++ b/message/validation/request_auth_test.go @@ -32,24 +32,24 @@ func TestSignerState_RequestAuthRoots(t *testing.T) { r1 := [32]byte{1} r2 := [32]byte{2} - require.Equal(t, 0, s.requestAuthRootCount()) - require.False(t, s.hasRequestAuthRoot(r1)) + require.Empty(t, s.SeenRequestAuthRoots) + require.False(t, s.SeenRequestAuthRoots.has(r1)) - s.recordRequestAuthRoot(r1) - require.True(t, s.hasRequestAuthRoot(r1)) - require.Equal(t, 1, s.requestAuthRootCount()) + s.SeenRequestAuthRoots.record(r1) + require.True(t, s.SeenRequestAuthRoots.has(r1)) + require.Len(t, s.SeenRequestAuthRoots, 1) // Recording an already-seen root is a no-op. - s.recordRequestAuthRoot(r1) - require.Equal(t, 1, s.requestAuthRootCount()) + s.SeenRequestAuthRoots.record(r1) + require.Len(t, s.SeenRequestAuthRoots, 1) - s.recordRequestAuthRoot(r2) - require.Equal(t, 2, s.requestAuthRootCount()) + s.SeenRequestAuthRoots.record(r2) + require.Len(t, s.SeenRequestAuthRoots, 2) // The two root sets are independent: the same root counts once per type, not globally. - s.recordProposerPreferencesRoot(r1) - require.Equal(t, 1, s.proposerPreferencesRootCount()) - require.Equal(t, 2, s.requestAuthRootCount()) + s.SeenProposerPreferencesRoots.record(r1) + require.Len(t, s.SeenProposerPreferencesRoots, 1) + require.Len(t, s.SeenRequestAuthRoots, 2) } // RequestAuth pre-consensus admits up to maxRequestAuthDistinctRoots distinct signing roots per @@ -65,8 +65,8 @@ func TestValidatePartialSignatureMessageLimit_RequestAuth(t *testing.T) { } } record := func(ss *SignerStateForSlotRound, from peer.ID, root [32]byte) { - ss.Peer(from).recordRequestAuthRoot(root) - ss.World.recordRequestAuthRoot(root) + ss.Peer(from).SeenRequestAuthRoots.record(root) + ss.World.SeenRequestAuthRoots.record(root) } root := func(b byte) [32]byte { return [32]byte{b} } @@ -109,15 +109,15 @@ func TestValidatePartialSignatureMessageLimit_RequestAuth(t *testing.T) { t.Run("§5 preference roots do not consume the request-auth budget (and vice versa)", func(t *testing.T) { ss := newSignerState(1, specqbft.FirstRound) for i := 0; i < maxProposerPreferencesDistinctRoots; i++ { - ss.Peer(peerA).recordProposerPreferencesRoot(root(byte(100 + i))) - ss.World.recordProposerPreferencesRoot(root(byte(100 + i))) + ss.Peer(peerA).SeenProposerPreferencesRoots.record(root(byte(100 + i))) + ss.World.SeenProposerPreferencesRoots.record(root(byte(100 + i))) } // The §5 budget is spent; a request-auth root is still admitted. require.NoError(t, validatePartialSignatureMessageLimit(raMsg(root(1)), peerA, ss)) record(ss, peerA, root(1)) // And the request-auth root did not consume the §5 budget's tracking. - require.Equal(t, 1, ss.World.requestAuthRootCount()) - require.Equal(t, maxProposerPreferencesDistinctRoots, ss.World.proposerPreferencesRootCount()) + require.Len(t, ss.World.SeenRequestAuthRoots, 1) + require.Len(t, ss.World.SeenProposerPreferencesRoots, maxProposerPreferencesDistinctRoots) }) } diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 90f12686d4..7b9b9a6c3e 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -9,6 +9,7 @@ import ( "github.com/libp2p/go-libp2p/core/peer" specqbft "github.com/ssvlabs/ssv-spec/qbft" + spectypes "github.com/ssvlabs/ssv-spec/types" ) // SignerStateForSlotRound is a SignerState bundled with some target slot+round. @@ -73,46 +74,36 @@ type SignerState struct { // SeenProposerPreferencesRoots records the distinct ProposerPreferences signing roots seen from this // signer (SIP #94 §5): that type is capped by distinct root (up to maxProposerPreferencesDistinctRoots), // not by the single pre-consensus bit in SeenMsgTypes. nil until the first such message. - SeenProposerPreferencesRoots [][32]byte + SeenProposerPreferencesRoots seenRootSet // SeenRequestAuthRoots records the distinct RequestAuthV1 signing roots seen from this signer // (issue #2962) — root-capped like the §5 preference roots above, up to // maxRequestAuthDistinctRoots. nil until the first such message. - SeenRequestAuthRoots [][32]byte + SeenRequestAuthRoots seenRootSet } -// hasProposerPreferencesRoot reports whether root has already been seen from this signer. -func (s *SignerState) hasProposerPreferencesRoot(root [32]byte) bool { - return slices.Contains(s.SeenProposerPreferencesRoots, root) -} +// seenRootSet tracks the distinct signing roots seen from a signer for a root-budgeted message +// type; growth is bounded by the type's budget, enforced before recording. +type seenRootSet [][32]byte -// proposerPreferencesRootCount returns the number of distinct roots seen from this signer. -func (s *SignerState) proposerPreferencesRootCount() int { - return len(s.SeenProposerPreferencesRoots) -} +func (s seenRootSet) has(root [32]byte) bool { return slices.Contains(s, root) } -// recordProposerPreferencesRoot adds root to the seen set, skipping roots already present. -func (s *SignerState) recordProposerPreferencesRoot(root [32]byte) { - if slices.Contains(s.SeenProposerPreferencesRoots, root) { - return +// record adds the root, skipping roots already present. +func (s *seenRootSet) record(root [32]byte) { + if !slices.Contains(*s, root) { + *s = append(*s, root) } - s.SeenProposerPreferencesRoots = append(s.SeenProposerPreferencesRoots, root) -} - -// hasRequestAuthRoot reports whether the request-auth root has already been seen from this signer. -func (s *SignerState) hasRequestAuthRoot(root [32]byte) bool { - return slices.Contains(s.SeenRequestAuthRoots, root) -} - -// requestAuthRootCount returns the number of distinct request-auth roots seen from this signer. -func (s *SignerState) requestAuthRootCount() int { - return len(s.SeenRequestAuthRoots) } -// recordRequestAuthRoot adds the request-auth root to the seen set, skipping roots already present. -func (s *SignerState) recordRequestAuthRoot(root [32]byte) { - if slices.Contains(s.SeenRequestAuthRoots, root) { - return +// seenRootsFor returns the signer's seen-root set for a root-budgeted message type; nil for types +// without one. +func seenRootsFor(s *SignerState, t spectypes.PartialSigMsgType) *seenRootSet { + switch t { + case spectypes.ProposerPreferencesPartialSig: + return &s.SeenProposerPreferencesRoots + case spectypes.RequestAuthPartialSig: + return &s.SeenRequestAuthRoots + default: + return nil } - s.SeenRequestAuthRoots = append(s.SeenRequestAuthRoots, root) } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index d7b6e459c7..9521d1d481 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -9,9 +9,10 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" - spectypes "github.com/ssvlabs/ssv-spec/types" "go.uber.org/zap" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/ssvlabs/ssv/observability/log/fields" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" protocolp2p "github.com/ssvlabs/ssv/protocol/v2/p2p" @@ -125,7 +126,13 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za // root anyway. A stashed preference partial that doesn't match the freshly frozen preference // fails signature verification inside the sub-runner and is skipped; a stashed request-auth // partial outside the freshly frozen auth-root set is skipped the same way. - if sub.hasDutyRunning() { + // + // The gate is duty-ASSIGNED, not running: a re-emission that concluded immediately (unchanged + // preference → not-required sets State.Succeeded) still must replay auth partials into its + // fresh container, or a yet-unreconstructed auth could never reach quorum again. Preference + // partials replayed into a concluded duty bounce off the succeeded-gate harmlessly; the auth + // rounds have no such gate by design. + if sub.hasDutyAssigned() { for _, stashed := range r.pending[slot] { if err := sub.ProcessPreConsensus(ctx, logger, stashed); err != nil { logger.Debug("skipped stashed proposer-preferences partial on replay", @@ -331,7 +338,9 @@ type proposerPreferencesSlotRunner struct { // requestAuths maps each frozen RequestAuthV1's signing root to the object and its builders; // incoming RequestAuthPartialSig messages are admitted only against these roots. nil until the - // duty executes here. + // duty executes here; empty when it executed but could freeze nothing (domain-fetch failure), + // so peer partials hard-fail as unknown roots instead of burning queue retries — the + // dispatcher stash keeps them for replay should a re-emission freeze successfully. requestAuths map[[32]byte]*frozenRequestAuth // requestAuthContainer collects request-auth partials separately from the preference round: @@ -343,25 +352,15 @@ type proposerPreferencesSlotRunner struct { // broadcastPreferences: auth roots are re-emission-invariant, so a replacement must neither // re-broadcast a root already out (a same-peer duplicate, issue #2934) nor redo a // reconstruction its stash replay would re-reach quorum for. + // + // TODO(gloas): the markers are in-memory, so a restart mid-lookahead re-broadcasts every auth + // root and peers REJECT the same-peer duplicates — up to entry-cap+1 penalized messages per + // pending slot (§5 preference included), vs 1 pre-overlay. Gauge on a builders-configured + // devnet before considering persistence. broadcastAuthRoots map[[32]byte]struct{} reconstructedAuthRoots map[[32]byte]struct{} } -// frozenRequestAuth pairs a frozen RequestAuthV1 with every configured builder relationship it -// authenticates: the signing root derives from (data, slot) alone — not the URL — so distinct -// entries sharing one pre-agreed token converge on one root, one broadcast, and one reconstruction -// serving them all. -type frozenRequestAuth struct { - auth *gloas.RequestAuthV1 - builders []frozenBuilderRef -} - -// frozenBuilderRef names one configured builder relationship covered by a frozen auth. -type frozenBuilderRef struct { - identity string // gloas.BuilderIdentity — the RequestAuthCache key - url string // for logging -} - func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { return &proposerPreferencesSlotRunner{ BaseRunner: &BaseRunner{ @@ -544,142 +543,6 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger return nil } -// runRequestAuthRound freezes one RequestAuthV1{data, proposal_slot} per authenticatable configured -// builder, records its signing root so incoming partials can be admitted, and broadcasts this -// operator's partial — once per root, across re-emissions. Per-builder failures are logged and -// skipped, never failing the §5 duty: a builder whose auth misses quorum is simply not contactable -// for the slot, and the enshrined flow (gossip bids, self-build) stays available. -func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, logger *zap.Logger, validatorDuty *spectypes.ValidatorDuty, proposalSlot phase0.Slot) { - if len(r.builders) == 0 { - return - } - - // DomainRequestAuth is genesis-style (computed locally by the beacon adapter, no BN call); the - // epoch argument is ignored for it. - domain, err := r.beacon.DomainData(ctx, r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) - if err != nil { - logger.Warn("request auth skipped: could not get domain data", fields.Slot(proposalSlot), zap.Error(err)) - return - } - - r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) - for i := range r.builders { - entry := &r.builders[i] - data, err := entry.AuthDataBytes() - if err != nil || len(data) == 0 { - continue // the default (empty-URL) entry has nothing to authenticate; invalid data is rejected at startup - } - auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} - root, err := spectypes.ComputeETHSigningRoot(auth, domain) - if err != nil { - logger.Warn("request auth skipped: could not compute signing root", - fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) - continue - } - ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} - if frozen, ok := r.requestAuths[root]; ok { - // Another entry froze these exact bytes; register the extra relationship on the shared root. - frozen.builders = append(frozen.builders, ref) - continue - } - r.requestAuths[root] = &frozenRequestAuth{auth: auth, builders: []frozenBuilderRef{ref}} - - if _, done := r.broadcastAuthRoots[root]; done { - continue // broadcast by a prior incarnation; stash replay and live partials finish its quorum - } - msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainRequestAuth), domain) - if err != nil { - logger.Warn("request auth skipped: could not sign", - fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) - continue - } - msgs := &spectypes.PartialSignatureMessages{ - Type: spectypes.RequestAuthPartialSig, - Slot: proposalSlot, - Messages: []*spectypes.PartialSignatureMessage{msg}, - } - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { - logger.Warn("request auth skipped: could not broadcast partial", - fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) - continue - } - r.broadcastAuthRoots[root] = struct{}{} - } -} - -// processRequestAuthPartial collects request-auth partials into their own container and, on the -// first quorum for a root, reconstructs the builder-facing SignedRequestAuthV1 into the shared -// cache. No succeeded-gate: the preference submission concluding the duty must not stop auth -// collection, which legitimately runs until the sub-runner is evicted. -func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { - if !r.hasDutyAssigned() { - return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) - } - if err := r.validatePartialSigMsg(signedMsg, r.State.CurrentDuty.DutySlot()); err != nil { - return fmt.Errorf("invalid request-auth partial: %w", err) - } - // The auth root, unlike the §5 preference, doesn't bind the validator index — tie the message - // to this runner's share explicitly, as the post-consensus paths do. - if err := r.validateValidatorIndexInPartialSigMsg(signedMsg); err != nil { - return err - } - if len(signedMsg.Messages) != 1 { - return errors.New("request-auth partial must carry exactly one message") - } - msg := signedMsg.Messages[0] - - if r.requestAuths == nil { - if len(r.builders) == 0 { - // No overlay here (never configured, or disabled for a remote signer): no root will - // ever be frozen for this slot, so retrying cannot help. - return errors.New("no builders configured") - } - // Duty assigned but not executed here yet: retryable, so a partial racing the duty start - // also lands via the queue replay and the dispatcher stash. - return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, errors.New("no frozen request auths"))) - } - frozen, ok := r.requestAuths[msg.SigningRoot] - if !ok { - // The sender's builder list or auth-data bytes diverge from ours; whatever quorum this root - // can reach forms on the operators that share its config. - return fmt.Errorf("unknown request-auth signing root %x", msg.SigningRoot) - } - if _, done := r.reconstructedAuthRoots[msg.SigningRoot]; done { - return nil // reconstructed and cached, possibly by a prior incarnation; late partials add nothing - } - - // quorum returns true only once per root (the first time it is reached). - hasQuorum, _ := r.basePartialSigMsgProcessing(signedMsg, r.requestAuthContainer) - if !hasQuorum { - return nil - } - - fullSig, err := r.State.ReconstructBeaconSig(r.requestAuthContainer, msg.SigningRoot, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) - if err != nil { - // If the reconstructed signature is invalid, surface which partial signatures were at fault. - r.FallBackAndVerifyEachSignature(r.requestAuthContainer, msg.SigningRoot, r.GetShare().Committee, r.GetShare().ValidatorIndex) - return fmt.Errorf("got request-auth quorum but it has invalid signatures: %w", err) - } - var signature phase0.BLSSignature - copy(signature[:], fullSig) - - r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} - urls := make([]string, 0, len(frozen.builders)) - for _, ref := range frozen.builders { - urls = append(urls, ref.url) - } - if r.requestAuthCache != nil { - signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} - for _, ref := range frozen.builders { - r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) - } - } - recordRequestAuthReconstruction(ctx) - logger.Info("✔️ reconstructed builder request auth", - fields.Slot(frozen.auth.Slot), zap.Strings("builder_urls", urls)) - return nil -} - // buildProposerPreferences assembles the preference for the proposal slot from this operator's own // view: fee recipient and target gas limit from validator config (matching validator registration), // and the dependent_root of the proposer duties for the proposal slot's epoch — the seed that fixed diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go new file mode 100644 index 0000000000..f76cdf0d51 --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -0,0 +1,178 @@ +package runner + +import ( + "context" + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "go.uber.org/zap" + + spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/observability/log/fields" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// The §5 dispatcher's request-auth rounds (issue #2962 B1): threshold-signing one RequestAuthV1 +// per configured builder, riding the proposer-preferences duty. The per-slot auth state lives on +// proposerPreferencesSlotRunner (proposer_preferences.go); this file holds the round logic. + +// frozenRequestAuth pairs a frozen RequestAuthV1 with every configured builder relationship it +// authenticates: the signing root derives from (data, slot) alone — not the URL — so distinct +// entries sharing one pre-agreed token converge on one root, one broadcast, and one reconstruction +// serving them all. +type frozenRequestAuth struct { + auth *gloas.RequestAuthV1 + builders []frozenBuilderRef +} + +// frozenBuilderRef names one configured builder relationship covered by a frozen auth. +type frozenBuilderRef struct { + identity string // gloas.BuilderIdentity — the RequestAuthCache key + url string // for logging +} + +// runRequestAuthRound freezes one RequestAuthV1{data, proposal_slot} per authenticatable configured +// builder, records its signing root so incoming partials can be admitted, and broadcasts this +// operator's partial — once per root, across re-emissions. Per-builder failures are logged and +// skipped, never failing the §5 duty: a builder whose auth misses quorum is simply not contactable +// for the slot, and the enshrined flow (gossip bids, self-build) stays available. +func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, logger *zap.Logger, validatorDuty *spectypes.ValidatorDuty, proposalSlot phase0.Slot) { + if len(r.builders) == 0 { + return + } + + // DomainRequestAuth is genesis-style (computed locally by the beacon adapter, no BN call); the + // epoch argument is ignored for it. + domain, err := r.beacon.DomainData(ctx, r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + if err != nil { + // Freeze an empty root set: executed-but-froze-nothing (see the requestAuths field doc). + r.requestAuths = map[[32]byte]*frozenRequestAuth{} + logger.Warn("request auth skipped: could not get domain data", fields.Slot(proposalSlot), zap.Error(err)) + return + } + + r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) + for i := range r.builders { + entry := &r.builders[i] + data, err := entry.AuthDataBytes() + if err != nil { + // Unreachable when startup validation ran, but never swallow a real error silently. + logger.Warn("request auth skipped: invalid auth data", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + if len(data) == 0 { + continue // the default (empty-URL) entry has nothing to authenticate + } + auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + if err != nil { + logger.Warn("request auth skipped: could not compute signing root", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} + if frozen, ok := r.requestAuths[root]; ok { + // Another entry froze these exact bytes; register the extra relationship on the shared root. + frozen.builders = append(frozen.builders, ref) + continue + } + r.requestAuths[root] = &frozenRequestAuth{auth: auth, builders: []frozenBuilderRef{ref}} + + if _, done := r.broadcastAuthRoots[root]; done { + continue // broadcast by a prior incarnation; stash replay and live partials finish its quorum + } + msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainRequestAuth), domain) + if err != nil { + logger.Warn("request auth skipped: could not sign", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + msgs := &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{msg}, + } + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + logger.Warn("request auth skipped: could not broadcast partial", + fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) + continue + } + r.broadcastAuthRoots[root] = struct{}{} + } +} + +// processRequestAuthPartial collects request-auth partials into their own container and, on the +// first quorum for a root, reconstructs the builder-facing SignedRequestAuthV1 into the shared +// cache. No succeeded-gate: the preference submission concluding the duty must not stop auth +// collection, which legitimately runs until the sub-runner is evicted. +func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + if !r.hasDutyAssigned() { + return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)) + } + if err := r.validatePartialSigMsg(signedMsg, r.State.CurrentDuty.DutySlot()); err != nil { + return fmt.Errorf("invalid request-auth partial: %w", err) + } + // The auth root, unlike the §5 preference, doesn't bind the validator index — tie the message + // to this runner's share explicitly, as the post-consensus paths do. + if err := r.validateValidatorIndexInPartialSigMsg(signedMsg); err != nil { + return err + } + if len(signedMsg.Messages) != 1 { + return errors.New("request-auth partial must carry exactly one message") + } + msg := signedMsg.Messages[0] + + if r.requestAuths == nil { + if len(r.builders) == 0 { + // No overlay here (never configured, or disabled for a remote signer): no root will + // ever be frozen for this slot, so retrying cannot help. + return errors.New("no builders configured") + } + // Duty assigned but not executed here yet: retryable, so a partial racing the duty start + // also lands via the queue replay and the dispatcher stash. + return NewRetryableError(spectypes.WrapError(spectypes.NoRunningDutyErrorCode, errors.New("no frozen request auths"))) + } + frozen, ok := r.requestAuths[msg.SigningRoot] + if !ok { + // The sender's builder list or auth-data bytes diverge from ours; whatever quorum this root + // can reach forms on the operators that share its config. + return fmt.Errorf("unknown request-auth signing root %x", msg.SigningRoot) + } + if _, done := r.reconstructedAuthRoots[msg.SigningRoot]; done { + return nil // reconstructed and cached, possibly by a prior incarnation; late partials add nothing + } + + // quorum returns true only once per root (the first time it is reached). + hasQuorum, _ := r.basePartialSigMsgProcessing(signedMsg, r.requestAuthContainer) + if !hasQuorum { + return nil + } + + fullSig, err := r.State.ReconstructBeaconSig(r.requestAuthContainer, msg.SigningRoot, r.GetShare().ValidatorPubKey[:], r.GetShare().ValidatorIndex) + if err != nil { + // If the reconstructed signature is invalid, surface which partial signatures were at fault. + r.FallBackAndVerifyEachSignature(r.requestAuthContainer, msg.SigningRoot, r.GetShare().Committee, r.GetShare().ValidatorIndex) + return fmt.Errorf("got request-auth quorum but it has invalid signatures: %w", err) + } + var signature phase0.BLSSignature + copy(signature[:], fullSig) + + r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} + urls := make([]string, 0, len(frozen.builders)) + for _, ref := range frozen.builders { + urls = append(urls, ref.url) + } + if r.requestAuthCache != nil { + signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} + for _, ref := range frozen.builders { + r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) + } + } + recordRequestAuthReconstruction(ctx) + logger.Info("✔️ reconstructed builder request auth", + fields.Slot(frozen.auth.Slot), zap.Strings("builder_urls", urls)) + return nil +} diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index 10be5ee004..a74bd160ed 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -305,3 +305,116 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. require.Len(t, cache.Get(proposalSlot), 1, "auth must reconstruct even after the §5 preference concluded the duty") } + +// A re-emission that concludes immediately (unchanged preference → not-required, e.g. an +// indices-change re-emit) replaces the sub-runner and its containers. The stash replay into the +// replacement must run even though the duty is already concluded — peers broadcast their partials +// exactly once, so an auth still short of quorum could otherwise never reach it. +func TestProposerPreferencesRunner_requestAuthSurvivesConcludedReemission(t *testing.T) { + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + cfg := cloneTestNetworkConfig() + const quorum = 3 + const gasLimit = 36_000_000 + feeRecipient := bellatrix.ExecutionAddress{0xfe} + + bn := &prefsTestBeacon{BeaconNode: protocoltesting.NewTestingBeaconNodeWrapped(), dependentRoot: phase0.Root{0xaa}} + network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) + cache := ssv.NewRequestAuthCache(cfg.EstimatedCurrentSlot) + builders := []gloas.BuilderEntry{{URL: "https://builder-a.example.com"}} + + runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + NetworkConfig: cfg, + Share: map[phase0.ValidatorIndex]*spectypes.Share{share.ValidatorIndex: share}, + Beacon: bn, + Network: network, + Signer: ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()), + OperatorSigner: spectestingutils.NewOperatorSigner(keySet, 1), + }, + FeeRecipientProvider: fixedFeeRecipientProvider{addr: feeRecipient}, + GasLimit: gasLimit, + Builders: builders, + RequestAuthCache: cache, + }) + require.NoError(t, err) + disp := runnerIface.(*ProposerPreferencesRunner) + + proposalSlot := cfg.EstimatedCurrentSlot() + 5 + duty := &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleProposerPreferences, + PubKey: spectestingutils.TestingValidatorPubKey, + Slot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + } + ctx := context.Background() + logger := zap.NewNop() + + peerPreferencePartial := func(t *testing.T, opID spectypes.OperatorID) *spectypes.PartialSignatureMessages { + t.Helper() + prefs := &gloas.ProposerPreferences{ + DependentRoot: bn.dependentRoot, + ProposalSlot: proposalSlot, + ValidatorIndex: share.ValidatorIndex, + FeeRecipient: feeRecipient, + TargetGasLimit: gasLimit, + } + domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainProposerPreferences)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(prefs, domain) + require.NoError(t, err) + sig := keySet.Shares[opID].SignByte(root[:]) + return &spectypes.PartialSignatureMessages{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: opID, + ValidatorIndex: share.ValidatorIndex, + }}, + } + } + peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID) *spectypes.PartialSignatureMessages { + t.Helper() + auth := &gloas.RequestAuthV1{Data: []byte("https://builder-a.example.com"), Slot: proposalSlot} + domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + require.NoError(t, err) + root, err := spectypes.ComputeETHSigningRoot(auth, domain) + require.NoError(t, err) + sig := keySet.Shares[opID].SignByte(root[:]) + return &spectypes.PartialSignatureMessages{ + Type: spectypes.RequestAuthPartialSig, + Slot: proposalSlot, + Messages: []*spectypes.PartialSignatureMessage{{ + PartialSignature: sig.Serialize(), + SigningRoot: root, + Signer: opID, + ValidatorIndex: share.ValidatorIndex, + }}, + } + } + + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + + // The §5 preference reaches quorum and submits: the duty concludes succeeded. + for _, op := range []spectypes.OperatorID{2, 3, 4} { + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerPreferencePartial(t, op))) + } + require.Len(t, bn.submitted, 1) + + // Two auth partials arrive — one short of quorum. + for _, op := range []spectypes.OperatorID{2, 3} { + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, op))) + } + require.Empty(t, cache.Get(proposalSlot)) + + // An unchanged re-emission concludes not-required immediately; the stash must still replay + // into the replacement's fresh container. + require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) + + // The third partial completes the quorum. + require.NoError(t, disp.ProcessPreConsensus(ctx, logger, peerAuthPartial(t, 4))) + require.Len(t, cache.Get(proposalSlot), 1, + "stash replay into the concluded replacement must let the auth quorum complete") +} diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 1a85ed1f75..49d752db0f 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -129,7 +129,8 @@ func ValidateBuilderEntries(entries []BuilderEntry) error { if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) } - if len(e.URL) > MaxRequestAuthDataSize { + // The URL's bytes are signed only when they serve as the default auth data. + if e.AuthData == "" && len(e.URL) > MaxRequestAuthDataSize { return fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxRequestAuthDataSize) } } diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index 410a016f62..1764bd15d7 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -89,4 +89,12 @@ func TestValidateBuilderEntries(t *testing.T) { "invalid PubKey hex") require.NoError(t, ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x" + strings.Repeat("ab", 48)}})) + + // A URL longer than the auth-data limit only matters when its bytes ARE the auth data. + longURL := "https://x.example/" + strings.Repeat("a", MaxRequestAuthDataSize) + require.ErrorContains(t, + ValidateBuilderEntries([]BuilderEntry{{URL: longURL}}), + "exceeding") + require.NoError(t, + ValidateBuilderEntries([]BuilderEntry{{URL: longURL, AuthData: "0x0102"}})) } From e2d9098f3cafdd4ce96f25539cf998053115a728 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 29 Jul 2026 22:13:53 +0300 Subject: [PATCH 126/150] gloas: retire the GlamsterdamDevnet networkconfig stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public-devnet SSV-cluster target is fully retired (#2920 closed in favor of the split successors): e2e rides the Aetheria local_testnet_gloas net and the Sepolia/Hoodi Gloas forks, so this placeholder config — zero registry address, bootnodes TBD — would never be filled in. The zero-registry-address guard it motivated in SSVConfigByName stays; it protects any future placeholder config the same way. --- networkconfig/glamsterdam-devnet.go | 41 ----------------------------- networkconfig/ssv.go | 2 -- 2 files changed, 43 deletions(-) delete mode 100644 networkconfig/glamsterdam-devnet.go diff --git a/networkconfig/glamsterdam-devnet.go b/networkconfig/glamsterdam-devnet.go deleted file mode 100644 index f4b851439d..0000000000 --- a/networkconfig/glamsterdam-devnet.go +++ /dev/null @@ -1,41 +0,0 @@ -package networkconfig - -import ( - "math/big" - - ethcommon "github.com/ethereum/go-ethereum/common" - - spectypes "github.com/ssvlabs/ssv-spec/types" -) - -// GlamsterdamDevnetSSV is the SSV config for running against an ethpandaops Glamsterdam (Gloas / -// ePBS) devnet — currently devnet-6 (chain 7052886157, genesis 1782386940 ≈ 2026-06-25; verified -// live 2026-06-30 at epoch ~1118, so GLOAS_FORK_EPOCH 30 is well in the past). The beacon config -// (genesis, fork schedule incl. GLOAS_FORK_EPOCH) is read from the BN at runtime; only the -// SSV-side values live here. -// -// Devnets are ephemeral and the SSV contracts are deployed per-network, so the values still marked -// TODO (the SSV contract address + sync offset, and the operator bootnode ENRs) must be filled -// after the contract deploy + operator/validator registration, and the whole block re-checked -// whenever the devnet is reset or replaced (devnet-5 → devnet-6 already happened; probe -// https://glamsterdam-devnet-N.ethpandaops.io/ to find the live one). -var GlamsterdamDevnetSSV = &SSV{ - Name: "glamsterdam-devnet", - DomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x00}, - NextDomainType: spectypes.DomainType{0x0, 0x0, 0x09, 0x01}, - - // TODO(e2e): SSV contract address + its deployment block, set after deploying on the devnet-6 EL. - RegistryContractAddr: ethcommon.Address{}, - RegistrySyncOffset: big.NewInt(0), - - DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, - // TODO(e2e): the 4 operators' bootnode ENRs (discovery seeds for the cluster). - Bootnodes: nil, - - // Approximate active-validator count (feeds gossip message-rate scoring only); ≈ the verified - // active set on devnet-6 (3909) as of 2026-06-30 — refresh on devnet reset/replace. - TotalEthereumValidators: 3909, - - // Boole is the SSV protocol baseline ePBS builds on — active from genesis on the devnet. - Forks: SSVForks{Boole: 0}, -} diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index 1d7d5e0ec9..6fae2f0098 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -21,8 +21,6 @@ var supportedSSVConfigs = map[string]*SSV{ HoodiSSV.Name: HoodiSSV, HoodiStageSSV.Name: HoodiStageSSV, SepoliaSSV.Name: SepoliaSSV, - - GlamsterdamDevnetSSV.Name: GlamsterdamDevnetSSV, } func SSVConfigByName(name string) (*SSV, error) { From d007683b3b6ed28769228808484ab893c586a647 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 5 Aug 2026 19:03:44 +0300 Subject: [PATCH 127/150] gloas: group the Gloas beacon-node interfaces with the other *Calls blocks PTCCalls, ProposerPreferencesCalls, GloasProposerCalls and GloasEnvelopeCalls had accreted after the BeaconNode interface that embeds them. Move them up between VoluntaryExitCalls and DomainCalls so the declaration order mirrors the embed order and BeaconNode reads as the composition point. Pure move, no signature or doc changes; mock_client.go is regenerated since mockgen emits in source order. --- protocol/v2/blockchain/beacon/client.go | 88 ++-- protocol/v2/blockchain/beacon/mock_client.go | 454 +++++++++---------- 2 files changed, 271 insertions(+), 271 deletions(-) diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 2d3d0edbdc..a794532248 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -78,6 +78,50 @@ type VoluntaryExitCalls interface { SubmitVoluntaryExit(ctx context.Context, voluntaryExit *phase0.SignedVoluntaryExit) error } +// PTCCalls is the beacon-node surface for Gloas (ePBS) Payload Timeliness Committee duties: +// fetching assignments, producing the data to attest to, and submitting signed messages. +type PTCCalls interface { + // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch. + PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) + // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot. + PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) + // SubmitPayloadAttestationMessages submits signed PTC messages to the beacon node's pool. + SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error +} + +// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences (SIP #94 §5). +// go-eth2-client has no Gloas types, so these are hand-rolled over HTTP. +type ProposerPreferencesCalls interface { + // ProposerDutiesDependentRoot returns the proposer-duties dependent root for the epoch — the + // seed the proposer-lookahead is pinned to. go-eth2-client drops it, so it's fetched via raw HTTP. + ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) + // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. + SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error +} + +// GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks +// (SIP #94 §4). go-eth2-client has no Gloas types, so these are hand-rolled over HTTP against the +// merged produce-block-v4 / publish endpoints (beacon-APIs#580). +type GloasProposerCalls interface { + // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships + // separately in the §6 envelope, so the block carries only the execution-payload bid. + GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) + // SubmitGloasBeaconBlock publishes a signed Gloas block. + SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error +} + +// GloasEnvelopeCalls is the beacon-node surface for the §6 execution-payload envelope (SIP #94 §6): +// fetching the payload the proposer committed to (self-build) and publishing the signed envelope as its +// blinded form. Like the block calls, these are hand-rolled over HTTP against the merged beacon-APIs#580 +// endpoints. +type GloasEnvelopeCalls interface { + // GetExecutionPayloadEnvelope fetches the execution-payload envelope for the proposer's committed + // block, to be blinded, agreed in §6 QBFT, and signed. + GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) + // SubmitExecutionPayloadEnvelope publishes the signed envelope. + SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error +} + type DomainCalls interface { DomainData(ctx context.Context, epoch phase0.Epoch, domain phase0.DomainType) (phase0.Domain, error) } @@ -139,47 +183,3 @@ type BeaconNode interface { signer // TODO need to handle differently proposalPreparations } - -// PTCCalls is the beacon-node surface for Gloas (ePBS) Payload Timeliness Committee duties: -// fetching assignments, producing the data to attest to, and submitting signed messages. -type PTCCalls interface { - // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch. - PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) - // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot. - PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) - // SubmitPayloadAttestationMessages submits signed PTC messages to the beacon node's pool. - SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error -} - -// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences (SIP #94 §5). -// go-eth2-client has no Gloas types, so these are hand-rolled over HTTP. -type ProposerPreferencesCalls interface { - // ProposerDutiesDependentRoot returns the proposer-duties dependent root for the epoch — the - // seed the proposer-lookahead is pinned to. go-eth2-client drops it, so it's fetched via raw HTTP. - ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) - // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. - SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error -} - -// GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks -// (SIP #94 §4). go-eth2-client has no Gloas types, so these are hand-rolled over HTTP against the -// merged produce-block-v4 / publish endpoints (beacon-APIs#580). -type GloasProposerCalls interface { - // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships - // separately in the §6 envelope, so the block carries only the execution-payload bid. - GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) - // SubmitGloasBeaconBlock publishes a signed Gloas block. - SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error -} - -// GloasEnvelopeCalls is the beacon-node surface for the §6 execution-payload envelope (SIP #94 §6): -// fetching the payload the proposer committed to (self-build) and publishing the signed envelope as its -// blinded form. Like the block calls, these are hand-rolled over HTTP against the merged beacon-APIs#580 -// endpoints. -type GloasEnvelopeCalls interface { - // GetExecutionPayloadEnvelope fetches the execution-payload envelope for the proposer's committed - // block, to be blinded, agreed in §6 QBFT, and signed. - GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) - // SubmitExecutionPayloadEnvelope publishes the signed envelope. - SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error -} diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index e12ab0e4d9..fe452adda4 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -411,6 +411,233 @@ func (mr *MockVoluntaryExitCallsMockRecorder) SubmitVoluntaryExit(ctx, voluntary return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitVoluntaryExit", reflect.TypeOf((*MockVoluntaryExitCalls)(nil).SubmitVoluntaryExit), ctx, voluntaryExit) } +// MockPTCCalls is a mock of PTCCalls interface. +type MockPTCCalls struct { + ctrl *gomock.Controller + recorder *MockPTCCallsMockRecorder + isgomock struct{} +} + +// MockPTCCallsMockRecorder is the mock recorder for MockPTCCalls. +type MockPTCCallsMockRecorder struct { + mock *MockPTCCalls +} + +// NewMockPTCCalls creates a new mock instance. +func NewMockPTCCalls(ctrl *gomock.Controller) *MockPTCCalls { + mock := &MockPTCCalls{ctrl: ctrl} + mock.recorder = &MockPTCCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPTCCalls) EXPECT() *MockPTCCallsMockRecorder { + return m.recorder +} + +// PayloadAttestationData mocks base method. +func (m *MockPTCCalls) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationData", ctx, slot) + ret0, _ := ret[0].(*gloas.PayloadAttestationData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationData indicates an expected call of PayloadAttestationData. +func (mr *MockPTCCallsMockRecorder) PayloadAttestationData(ctx, slot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationData", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationData), ctx, slot) +} + +// PayloadAttestationDuties mocks base method. +func (m *MockPTCCalls) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PayloadAttestationDuties", ctx, epoch, validatorIndices) + ret0, _ := ret[0].([]*gloas.PTCDuty) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PayloadAttestationDuties indicates an expected call of PayloadAttestationDuties. +func (mr *MockPTCCallsMockRecorder) PayloadAttestationDuties(ctx, epoch, validatorIndices any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationDuties", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationDuties), ctx, epoch, validatorIndices) +} + +// SubmitPayloadAttestationMessages mocks base method. +func (m *MockPTCCalls) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitPayloadAttestationMessages", ctx, messages) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitPayloadAttestationMessages indicates an expected call of SubmitPayloadAttestationMessages. +func (mr *MockPTCCallsMockRecorder) SubmitPayloadAttestationMessages(ctx, messages any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitPayloadAttestationMessages", reflect.TypeOf((*MockPTCCalls)(nil).SubmitPayloadAttestationMessages), ctx, messages) +} + +// MockProposerPreferencesCalls is a mock of ProposerPreferencesCalls interface. +type MockProposerPreferencesCalls struct { + ctrl *gomock.Controller + recorder *MockProposerPreferencesCallsMockRecorder + isgomock struct{} +} + +// MockProposerPreferencesCallsMockRecorder is the mock recorder for MockProposerPreferencesCalls. +type MockProposerPreferencesCallsMockRecorder struct { + mock *MockProposerPreferencesCalls +} + +// NewMockProposerPreferencesCalls creates a new mock instance. +func NewMockProposerPreferencesCalls(ctrl *gomock.Controller) *MockProposerPreferencesCalls { + mock := &MockProposerPreferencesCalls{ctrl: ctrl} + mock.recorder = &MockProposerPreferencesCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProposerPreferencesCalls) EXPECT() *MockProposerPreferencesCallsMockRecorder { + return m.recorder +} + +// ProposerDutiesDependentRoot mocks base method. +func (m *MockProposerPreferencesCalls) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProposerDutiesDependentRoot", ctx, epoch) + ret0, _ := ret[0].(phase0.Root) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ProposerDutiesDependentRoot indicates an expected call of ProposerDutiesDependentRoot. +func (mr *MockProposerPreferencesCallsMockRecorder) ProposerDutiesDependentRoot(ctx, epoch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).ProposerDutiesDependentRoot), ctx, epoch) +} + +// SubmitProposerPreferences mocks base method. +func (m *MockProposerPreferencesCalls) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitProposerPreferences", ctx, preferences) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitProposerPreferences indicates an expected call of SubmitProposerPreferences. +func (mr *MockProposerPreferencesCallsMockRecorder) SubmitProposerPreferences(ctx, preferences any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposerPreferences", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).SubmitProposerPreferences), ctx, preferences) +} + +// MockGloasProposerCalls is a mock of GloasProposerCalls interface. +type MockGloasProposerCalls struct { + ctrl *gomock.Controller + recorder *MockGloasProposerCallsMockRecorder + isgomock struct{} +} + +// MockGloasProposerCallsMockRecorder is the mock recorder for MockGloasProposerCalls. +type MockGloasProposerCallsMockRecorder struct { + mock *MockGloasProposerCalls +} + +// NewMockGloasProposerCalls creates a new mock instance. +func NewMockGloasProposerCalls(ctrl *gomock.Controller) *MockGloasProposerCalls { + mock := &MockGloasProposerCalls{ctrl: ctrl} + mock.recorder = &MockGloasProposerCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGloasProposerCalls) EXPECT() *MockGloasProposerCallsMockRecorder { + return m.recorder +} + +// GetGloasBeaconBlock mocks base method. +func (m *MockGloasProposerCalls) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) + ret0, _ := ret[0].(*gloas.BeaconBlock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. +func (mr *MockGloasProposerCallsMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) +} + +// SubmitGloasBeaconBlock mocks base method. +func (m *MockGloasProposerCalls) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. +func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block) +} + +// MockGloasEnvelopeCalls is a mock of GloasEnvelopeCalls interface. +type MockGloasEnvelopeCalls struct { + ctrl *gomock.Controller + recorder *MockGloasEnvelopeCallsMockRecorder + isgomock struct{} +} + +// MockGloasEnvelopeCallsMockRecorder is the mock recorder for MockGloasEnvelopeCalls. +type MockGloasEnvelopeCallsMockRecorder struct { + mock *MockGloasEnvelopeCalls +} + +// NewMockGloasEnvelopeCalls creates a new mock instance. +func NewMockGloasEnvelopeCalls(ctrl *gomock.Controller) *MockGloasEnvelopeCalls { + mock := &MockGloasEnvelopeCalls{ctrl: ctrl} + mock.recorder = &MockGloasEnvelopeCallsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGloasEnvelopeCalls) EXPECT() *MockGloasEnvelopeCallsMockRecorder { + return m.recorder +} + +// GetExecutionPayloadEnvelope mocks base method. +func (m *MockGloasEnvelopeCalls) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecutionPayloadEnvelope", ctx, slot, beaconBlockRoot) + ret0, _ := ret[0].(*gloas.ExecutionPayloadEnvelope) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutionPayloadEnvelope indicates an expected call of GetExecutionPayloadEnvelope. +func (mr *MockGloasEnvelopeCallsMockRecorder) GetExecutionPayloadEnvelope(ctx, slot, beaconBlockRoot any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).GetExecutionPayloadEnvelope), ctx, slot, beaconBlockRoot) +} + +// SubmitExecutionPayloadEnvelope mocks base method. +func (m *MockGloasEnvelopeCalls) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitExecutionPayloadEnvelope", ctx, signed) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitExecutionPayloadEnvelope indicates an expected call of SubmitExecutionPayloadEnvelope. +func (mr *MockGloasEnvelopeCallsMockRecorder) SubmitExecutionPayloadEnvelope(ctx, signed any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).SubmitExecutionPayloadEnvelope), ctx, signed) +} + // MockDomainCalls is a mock of DomainCalls interface. type MockDomainCalls struct { ctrl *gomock.Controller @@ -1245,230 +1472,3 @@ func (mr *MockBeaconNodeMockRecorder) SyncCommitteeSubnetID(index any) *gomock.C mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncCommitteeSubnetID", reflect.TypeOf((*MockBeaconNode)(nil).SyncCommitteeSubnetID), index) } - -// MockPTCCalls is a mock of PTCCalls interface. -type MockPTCCalls struct { - ctrl *gomock.Controller - recorder *MockPTCCallsMockRecorder - isgomock struct{} -} - -// MockPTCCallsMockRecorder is the mock recorder for MockPTCCalls. -type MockPTCCallsMockRecorder struct { - mock *MockPTCCalls -} - -// NewMockPTCCalls creates a new mock instance. -func NewMockPTCCalls(ctrl *gomock.Controller) *MockPTCCalls { - mock := &MockPTCCalls{ctrl: ctrl} - mock.recorder = &MockPTCCallsMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPTCCalls) EXPECT() *MockPTCCallsMockRecorder { - return m.recorder -} - -// PayloadAttestationData mocks base method. -func (m *MockPTCCalls) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PayloadAttestationData", ctx, slot) - ret0, _ := ret[0].(*gloas.PayloadAttestationData) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// PayloadAttestationData indicates an expected call of PayloadAttestationData. -func (mr *MockPTCCallsMockRecorder) PayloadAttestationData(ctx, slot any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationData", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationData), ctx, slot) -} - -// PayloadAttestationDuties mocks base method. -func (m *MockPTCCalls) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PayloadAttestationDuties", ctx, epoch, validatorIndices) - ret0, _ := ret[0].([]*gloas.PTCDuty) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// PayloadAttestationDuties indicates an expected call of PayloadAttestationDuties. -func (mr *MockPTCCallsMockRecorder) PayloadAttestationDuties(ctx, epoch, validatorIndices any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PayloadAttestationDuties", reflect.TypeOf((*MockPTCCalls)(nil).PayloadAttestationDuties), ctx, epoch, validatorIndices) -} - -// SubmitPayloadAttestationMessages mocks base method. -func (m *MockPTCCalls) SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitPayloadAttestationMessages", ctx, messages) - ret0, _ := ret[0].(error) - return ret0 -} - -// SubmitPayloadAttestationMessages indicates an expected call of SubmitPayloadAttestationMessages. -func (mr *MockPTCCallsMockRecorder) SubmitPayloadAttestationMessages(ctx, messages any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitPayloadAttestationMessages", reflect.TypeOf((*MockPTCCalls)(nil).SubmitPayloadAttestationMessages), ctx, messages) -} - -// MockProposerPreferencesCalls is a mock of ProposerPreferencesCalls interface. -type MockProposerPreferencesCalls struct { - ctrl *gomock.Controller - recorder *MockProposerPreferencesCallsMockRecorder - isgomock struct{} -} - -// MockProposerPreferencesCallsMockRecorder is the mock recorder for MockProposerPreferencesCalls. -type MockProposerPreferencesCallsMockRecorder struct { - mock *MockProposerPreferencesCalls -} - -// NewMockProposerPreferencesCalls creates a new mock instance. -func NewMockProposerPreferencesCalls(ctrl *gomock.Controller) *MockProposerPreferencesCalls { - mock := &MockProposerPreferencesCalls{ctrl: ctrl} - mock.recorder = &MockProposerPreferencesCallsMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockProposerPreferencesCalls) EXPECT() *MockProposerPreferencesCallsMockRecorder { - return m.recorder -} - -// ProposerDutiesDependentRoot mocks base method. -func (m *MockProposerPreferencesCalls) ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ProposerDutiesDependentRoot", ctx, epoch) - ret0, _ := ret[0].(phase0.Root) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ProposerDutiesDependentRoot indicates an expected call of ProposerDutiesDependentRoot. -func (mr *MockProposerPreferencesCallsMockRecorder) ProposerDutiesDependentRoot(ctx, epoch any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).ProposerDutiesDependentRoot), ctx, epoch) -} - -// SubmitProposerPreferences mocks base method. -func (m *MockProposerPreferencesCalls) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitProposerPreferences", ctx, preferences) - ret0, _ := ret[0].(error) - return ret0 -} - -// SubmitProposerPreferences indicates an expected call of SubmitProposerPreferences. -func (mr *MockProposerPreferencesCallsMockRecorder) SubmitProposerPreferences(ctx, preferences any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitProposerPreferences", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).SubmitProposerPreferences), ctx, preferences) -} - -// MockGloasProposerCalls is a mock of GloasProposerCalls interface. -type MockGloasProposerCalls struct { - ctrl *gomock.Controller - recorder *MockGloasProposerCallsMockRecorder - isgomock struct{} -} - -// MockGloasProposerCallsMockRecorder is the mock recorder for MockGloasProposerCalls. -type MockGloasProposerCallsMockRecorder struct { - mock *MockGloasProposerCalls -} - -// NewMockGloasProposerCalls creates a new mock instance. -func NewMockGloasProposerCalls(ctrl *gomock.Controller) *MockGloasProposerCalls { - mock := &MockGloasProposerCalls{ctrl: ctrl} - mock.recorder = &MockGloasProposerCallsMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockGloasProposerCalls) EXPECT() *MockGloasProposerCallsMockRecorder { - return m.recorder -} - -// GetGloasBeaconBlock mocks base method. -func (m *MockGloasProposerCalls) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) - ret0, _ := ret[0].(*gloas.BeaconBlock) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. -func (mr *MockGloasProposerCallsMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) -} - -// SubmitGloasBeaconBlock mocks base method. -func (m *MockGloasProposerCalls) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) - ret0, _ := ret[0].(error) - return ret0 -} - -// SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. -func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block) -} - -// MockGloasEnvelopeCalls is a mock of GloasEnvelopeCalls interface. -type MockGloasEnvelopeCalls struct { - ctrl *gomock.Controller - recorder *MockGloasEnvelopeCallsMockRecorder - isgomock struct{} -} - -// MockGloasEnvelopeCallsMockRecorder is the mock recorder for MockGloasEnvelopeCalls. -type MockGloasEnvelopeCallsMockRecorder struct { - mock *MockGloasEnvelopeCalls -} - -// NewMockGloasEnvelopeCalls creates a new mock instance. -func NewMockGloasEnvelopeCalls(ctrl *gomock.Controller) *MockGloasEnvelopeCalls { - mock := &MockGloasEnvelopeCalls{ctrl: ctrl} - mock.recorder = &MockGloasEnvelopeCallsMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockGloasEnvelopeCalls) EXPECT() *MockGloasEnvelopeCallsMockRecorder { - return m.recorder -} - -// GetExecutionPayloadEnvelope mocks base method. -func (m *MockGloasEnvelopeCalls) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0.Slot, beaconBlockRoot phase0.Root) (*gloas.ExecutionPayloadEnvelope, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExecutionPayloadEnvelope", ctx, slot, beaconBlockRoot) - ret0, _ := ret[0].(*gloas.ExecutionPayloadEnvelope) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetExecutionPayloadEnvelope indicates an expected call of GetExecutionPayloadEnvelope. -func (mr *MockGloasEnvelopeCallsMockRecorder) GetExecutionPayloadEnvelope(ctx, slot, beaconBlockRoot any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).GetExecutionPayloadEnvelope), ctx, slot, beaconBlockRoot) -} - -// SubmitExecutionPayloadEnvelope mocks base method. -func (m *MockGloasEnvelopeCalls) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitExecutionPayloadEnvelope", ctx, signed) - ret0, _ := ret[0].(error) - return ret0 -} - -// SubmitExecutionPayloadEnvelope indicates an expected call of SubmitExecutionPayloadEnvelope. -func (mr *MockGloasEnvelopeCallsMockRecorder) SubmitExecutionPayloadEnvelope(ctx, signed any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitExecutionPayloadEnvelope", reflect.TypeOf((*MockGloasEnvelopeCalls)(nil).SubmitExecutionPayloadEnvelope), ctx, signed) -} From c343942cff32f5ac48512863284d07b9f2ed7a41 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 19:54:21 +0300 Subject: [PATCH 128/150] gloas: reconcile the request-auth overlay to the merged Gloas builder specs builder-specs#165, beacon-APIs#630, and keymanager-APIs#88 merged, renaming the request-auth objects and reshaping the direct-builder config. Match them: - Rename the wire types RequestAuthV1/SignedRequestAuthV1 -> BuilderRequestAuth/ SignedBuilderRequestAuth and MaxRequestAuthDataSize -> MaxBuilderAuthDataSize (the spectypes DomainRequestAuth/RequestAuthPartialSig stay; the 0x0B000001 value is what matters), and regenerate the SSZ. Fix the domain doc comment: the signing domain is genesis-style, but the wire type is fork-versioned, so hops carrying the body set Eth-Consensus-Version. - Reshape the operator Builders config to keymanager-APIs#88's BuilderConfig: top-level MinBid/BuilderBoostFactor (p2p bids + per-entry defaults) wrapping an Entries list; drop MaxTrustedBid; PubKey -> BuilderPubKeys list; remove the empty-URL default entry (URLs now required); ValidateBuilderEntries -> ValidateBuilderConfig; add the #88 EffectiveMinBid/EffectiveBoostFactor resolution (consumed by the phase-2 produceBlockV4 POST). - Refresh config.example.yaml and docs/EXTERNAL_BUILDERS.md to the new shape and the merged spec references. Phase-1 reconcile for #2962; phases 2-3 (produceBlockV4 POST, submitBuilderPreferences) remain pending upstream consumption. --- cli/operator/config.go | 4 +- cli/operator/node.go | 4 +- config/config.example.yaml | 30 +-- docs/EXTERNAL_BUILDERS.md | 22 +- message/validation/const.go | 2 +- message/validation/request_auth_test.go | 2 +- message/validation/signer_state.go | 2 +- operator/validator/controller.go | 2 +- protocol/v2/ssv/request_auth_cache.go | 12 +- protocol/v2/ssv/request_auth_cache_test.go | 4 +- .../v2/ssv/runner/proposer_preferences.go | 20 +- .../proposer_preferences_request_auth.go | 25 +-- protocol/v2/ssv/runner/request_auth_test.go | 23 +- protocol/v2/ssv/validator/opts.go | 2 +- protocol/v2/types/gloas/builder_entry.go | 205 ++++++++++-------- protocol/v2/types/gloas/builder_entry_test.go | 115 +++++----- protocol/v2/types/gloas/request_auth.go | 51 ++--- .../v2/types/gloas/request_auth_encoding.go | 62 +++--- protocol/v2/types/gloas/request_auth_test.go | 54 ++--- ssvsigner/ekm/local_key_manager.go | 2 +- ssvsigner/ekm/remote_key_manager.go | 2 +- 21 files changed, 326 insertions(+), 319 deletions(-) diff --git a/cli/operator/config.go b/cli/operator/config.go index cde91f2651..2c6baa78fe 100644 --- a/cli/operator/config.go +++ b/cli/operator/config.go @@ -50,7 +50,7 @@ type config struct { ProposerDelay time.Duration `yaml:"ProposerDelay" env:"PROPOSER_DELAY" env-description:"Duration to wait out before requesting Ethereum block to propose if this Operator is proposer-duty Leader (eg. 300ms). See https://github.com/ssvlabs/ssv/blob/main/docs/MEV_CONSIDERATIONS.md#getting-started-with-mev-configuration for detailed instructions on how to use it."` AllowDangerousProposerDelay bool `yaml:"AllowDangerousProposerDelay" env:"ALLOW_DANGEROUS_PROPOSER_DELAY" env-description:"Allow ProposerDelay values higher than 1s (dangerous, may cause missed block proposals)"` ProposerDelayEPBS time.Duration `yaml:"ProposerDelayEPBS" env:"PROPOSER_DELAY_EPBS" env-description:"Post-ePBS (Gloas) counterpart of ProposerDelay, applied from the Gloas fork on (ProposerDelay applies before it). Hard-capped at 1s with no dangerous override. Default 0 (opt-in)."` - Builders []gloas.BuilderEntry `yaml:"Builders" env-description:"Gloas (ePBS) direct-builder connections (opt-in overlay, YAML only). Entries must be configured identically across all operators of every shared committee; see docs/EXTERNAL_BUILDERS.md"` + Builders gloas.BuilderConfig `yaml:"Builders" env-description:"Gloas (ePBS) direct-builder connections (opt-in overlay, YAML only). Entries must be configured identically across all operators of every shared committee; see docs/EXTERNAL_BUILDERS.md"` OperatorPrivateKey string `yaml:"OperatorPrivateKey" env:"OPERATOR_KEY" env-description:"Operator private key for contract event decryption"` MetricsAPIPort int `yaml:"MetricsAPIPort" env:"METRICS_API_PORT" env-description:"Port for metrics API server"` EnableTraces bool `yaml:"EnableTraces" env:"ENABLE_TRACES" env-description:"Enable Open Telemetry traces"` @@ -167,7 +167,7 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) { c.ProposerDelayEPBS, maxSafeProposerDelay) } - if err := gloas.ValidateBuilderEntries(c.Builders); err != nil { + if err := gloas.ValidateBuilderConfig(c.Builders); err != nil { return resolved{}, fmt.Errorf("invalid Builders configuration: %w", err) } diff --git a/cli/operator/node.go b/cli/operator/node.go index aa0d9e9949..3cc82136be 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -232,11 +232,11 @@ func newNode( ) (_ *node, err error) { usingSSVSigner := res.usingSSVSigner - if len(cfg.Builders) > 0 && usingSSVSigner { + if len(cfg.Builders.Entries) > 0 && usingSSVSigner { // Web3Signer has no request-auth type, so this operator could never contribute an auth // partial — warn once here instead of once per builder per emission in the runner. logger.Warn("Builders configured with a remote signer: request-auth signing is unsupported there, disabling the direct-builder overlay on this operator (the cluster still reconstructs auths while at most f operators are remote-signing)") - cfg.Builders = nil + cfg.Builders.Entries = nil } identity, err := resolveOperatorIdentity(ctx, logger, cfg, res) diff --git a/config/config.example.yaml b/config/config.example.yaml index 6daeb67c70..610878becc 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -59,21 +59,23 @@ OperatorPrivateKey: # ProposerDelayEPBS: 0 # Post-ePBS (Gloas) direct-builder connections — an opt-in overlay on top of the enshrined builder flow -# (which needs no configuration and stays the fallback). Every entry MUST be configured identically across -# ALL operators of every committee sharing a validator: AuthData (defaulting to the UTF-8 bytes of URL, -# exactly as written) is threshold-signed, so any divergence silently disables that builder. At most 8 -# entries; one entry may have an empty URL to set default preferences for any contacted builder. -# Monetary values are Gwei. The bid-selection knobs (BuilderBoostFactor, MaxTrustedBid, MinBid, -# MaxExecutionPayment, PubKey) are validated but NOT YET HONORED — they take effect with the -# produceBlockV4 POST migration (beacon-APIs#625). See docs/EXTERNAL_BUILDERS.md. +# (which needs no configuration and stays the fallback). The whole block MUST be configured identically +# across ALL operators of every committee sharing a validator: AuthData (defaulting to the UTF-8 bytes of +# URL, exactly as written) is threshold-signed, so any divergence silently disables that builder. At most 8 +# entries. Monetary values are Gwei. The top-level MinBid/BuilderBoostFactor apply to p2p (gossiped) bids +# and, per keymanager-APIs#88, are the default for any entry that omits its own. The bid-selection knobs +# are validated but NOT YET HONORED — they take effect with the produceBlockV4 POST migration +# (beacon-APIs#630). See docs/EXTERNAL_BUILDERS.md. # Builders: -# - URL: "https://builder.example.com" -# # AuthData: "0x..." # omit to default to the URL bytes -# # BuilderBoostFactor: 100 # bid multiplier %, 0 = always local, default 100 -# # MaxTrustedBid: 0 # cap on trusted (off-protocol) bid value -# # MinBid: 0 # ignore bids below this value -# # MaxExecutionPayment: 0 # cap on trusted execution-layer payment -# # PubKey: "0x..." # optionally pin the builder's bid-signing key +# MinBid: 0 # p2p-bid floor + default for entries below; 0 = no floor +# BuilderBoostFactor: 100 # p2p-bid multiplier % + default; 0 = always local, 100 = neutral +# Entries: +# - URL: "https://builder.example.com" +# # AuthData: "0x..." # omit to default to the URL bytes +# # BuilderPubKeys: ["0x..."] # optionally pin the builder's bid-signing key(s); empty = any +# # MaxExecutionPayment: 0 # cap on trusted execution-layer payment +# # MinBid: 0 # optional; inherits the top-level MinBid +# # BuilderBoostFactor: 100 # optional; inherits the top-level BuilderBoostFactor # This enables monitoring at the specified port, see https://github.com/ssvlabs/ssv/tree/main/monitoring MetricsAPIPort: 15000 diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index 6ec03e64c4..a3f600f114 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -15,19 +15,20 @@ On top of the enshrined flow — gossiped bids from staked builders, with local always-available floor — a cluster MAY additionally maintain **direct builder connections**: authenticated bid requests and per-builder bid preferences, per the Gloas [builder-specs](https://github.com/ethereum/builder-specs/blob/master/specs/gloas/validator.md) and -[beacon-APIs#625](https://github.com/ethereum/beacon-APIs/pull/625). This is an **opt-in enhancement, not +[beacon-APIs#630](https://github.com/ethereum/beacon-APIs/pull/630). This is an **opt-in enhancement, not on the critical path**: a cluster that never configures it still proposes valid blocks, and the enshrined path stays the fallback whenever the overlay fails or a builder is unavailable. Design and rollout are tracked in [issue #2962](https://github.com/ssvlabs/ssv/issues/2962). -Configuration is the `Builders` list (see `config.example.yaml`), using the ecosystem's -[keymanager-APIs#87](https://github.com/ethereum/keymanager-APIs/pull/87) `BuilderEntry` vocabulary: -`URL`, `AuthData`, `BuilderBoostFactor`, `MaxTrustedBid`, `MinBid`, `MaxExecutionPayment`, optional -`PubKey`. +Configuration is the `Builders` block (see `config.example.yaml`), using the ecosystem's +[keymanager-APIs#88](https://github.com/ethereum/keymanager-APIs/pull/88) `BuilderConfig` vocabulary: +top-level `MinBid` and `BuilderBoostFactor` (applied to p2p bids, and the default for any entry that omits +its own) plus an `Entries` list — each entry `URL`, `AuthData`, optional `BuilderPubKeys`, +`MaxExecutionPayment`, `MinBid`, `BuilderBoostFactor`. **Every operator of every committee sharing a validator MUST configure the identical list — all `n` operators, not just a quorum.** The builder authenticates the cluster by one BLS signature over -`RequestAuthV1{data, slot}` reconstructed from operator partials, and the partials only combine over +`BuilderRequestAuth{data, slot}` reconstructed from operator partials, and the partials only combine over byte-identical `data`: - `AuthData` divergence on a builder entry splits the signing quorum and **silently disables that builder** @@ -35,12 +36,11 @@ byte-identical `data`: build-source metrics rather than proposal failures. - `AuthData` defaults to the UTF-8 bytes of `URL` exactly as configured — so even trailing-slash or case differences between operators' `URL` values break the quorum unless an explicit shared `AuthData` is set. -- The unsigned knobs (`BuilderBoostFactor`, `MaxTrustedBid`, `MinBid`, `MaxExecutionPayment`) don't affect - signing, but divergence makes the cluster's effective bid policy depend on which operator leads the - round — keep them identical too. They take effect with the produceBlockV4 POST migration - (beacon-APIs#625, pending upstream). +- The unsigned knobs (`MinBid`, `BuilderBoostFactor`, `MaxExecutionPayment`) don't affect signing, but + divergence makes the cluster's effective bid policy depend on which operator leads the round — keep them + identical too. They take effect with the produceBlockV4 POST migration (beacon-APIs#630). - Remote-signing operators (Web3Signer) cannot produce request-auth partials — there is no request-auth - signing type there yet. A node with `Builders` set and a remote signer warns at startup and disables the + signing type there yet. A node with `Builders` entries set and a remote signer warns at startup and disables the overlay locally; the cluster still reconstructs auths while at most `f` operators are remote-signing. ## How to use diff --git a/message/validation/const.go b/message/validation/const.go index 41d1e727de..77bc038b10 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -37,7 +37,7 @@ const proposerPreferencesEarlyEpochs = 2 // shared constant. const maxProposerPreferencesDistinctRoots = gloas.MaxProposerPreferencesDistinctRoots -// maxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one (slot, signer) +// maxRequestAuthDistinctRoots bounds the distinct BuilderRequestAuth signing roots one (slot, signer) // may contribute (issue #2962): exactly one per configured direct-builder entry. Derivation at the // shared constant. const maxRequestAuthDistinctRoots = gloas.MaxRequestAuthDistinctRoots diff --git a/message/validation/request_auth_test.go b/message/validation/request_auth_test.go index 245038d8bf..79df2a842e 100644 --- a/message/validation/request_auth_test.go +++ b/message/validation/request_auth_test.go @@ -25,7 +25,7 @@ func TestValidPartialSigMsgType_RequestAuth(t *testing.T) { require.True(t, mv.validPartialSigMsgType(spectypes.RequestAuthPartialSig)) } -// SignerState tracks distinct RequestAuthV1 signing roots independently of the §5 preference roots: +// SignerState tracks distinct BuilderRequestAuth signing roots independently of the §5 preference roots: // recording is idempotent per root, the two sets never bleed into each other's budgets. func TestSignerState_RequestAuthRoots(t *testing.T) { s := &SignerState{} diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 7b9b9a6c3e..587430a30e 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -76,7 +76,7 @@ type SignerState struct { // not by the single pre-consensus bit in SeenMsgTypes. nil until the first such message. SeenProposerPreferencesRoots seenRootSet - // SeenRequestAuthRoots records the distinct RequestAuthV1 signing roots seen from this signer + // SeenRequestAuthRoots records the distinct BuilderRequestAuth signing roots seen from this signer // (issue #2962) — root-capped like the §5 preference roots above, up to // maxRequestAuthDistinctRoots. nil until the first such message. SeenRequestAuthRoots seenRootSet diff --git a/operator/validator/controller.go b/operator/validator/controller.go index dba44f21a5..0cd922a482 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -88,7 +88,7 @@ type ControllerOptions struct { Graffiti []byte ProposerDelay time.Duration ProposerDelayEPBS time.Duration - Builders []gloas.BuilderEntry + Builders gloas.BuilderConfig // worker flags WorkersCount int `yaml:"MsgWorkersCount" env:"MSG_WORKERS_COUNT" env-description:"Number of message processing workers"` diff --git a/protocol/v2/ssv/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go index c2b2d06f10..25ff2b22e4 100644 --- a/protocol/v2/ssv/request_auth_cache.go +++ b/protocol/v2/ssv/request_auth_cache.go @@ -9,7 +9,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// RequestAuthCache holds, per proposal slot, the threshold-reconstructed SignedRequestAuthV1 for +// RequestAuthCache holds, per proposal slot, the threshold-reconstructed SignedBuilderRequestAuth for // each configured builder relationship (issue #2962 B1), keyed by gloas.BuilderIdentity. The §5 // slot sub-runners write on reconstruction quorum; there is no reader yet — the §4 produce path // (and later the ahead-of-time submitBuilderPreferences) becomes one with the produceBlockV4 POST @@ -22,26 +22,26 @@ type RequestAuthCache struct { currentSlot func() phase0.Slot mu sync.Mutex - auths map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1 + auths map[phase0.Slot]map[string]*gloas.SignedBuilderRequestAuth } func NewRequestAuthCache(currentSlot func() phase0.Slot) *RequestAuthCache { return &RequestAuthCache{ currentSlot: currentSlot, - auths: make(map[phase0.Slot]map[string]*gloas.SignedRequestAuthV1), + auths: make(map[phase0.Slot]map[string]*gloas.SignedBuilderRequestAuth), } } // Store records the reconstructed auth for the proposal slot under the builder identity, and // evicts slots the chain has moved past. Auths for future proposal slots are always kept — their // count is bounded by the proposer lookahead times the builder-entry cap. -func (c *RequestAuthCache) Store(slot phase0.Slot, builderIdentity string, auth *gloas.SignedRequestAuthV1) { +func (c *RequestAuthCache) Store(slot phase0.Slot, builderIdentity string, auth *gloas.SignedBuilderRequestAuth) { c.mu.Lock() defer c.mu.Unlock() byBuilder := c.auths[slot] if byBuilder == nil { - byBuilder = make(map[string]*gloas.SignedRequestAuthV1) + byBuilder = make(map[string]*gloas.SignedBuilderRequestAuth) c.auths[slot] = byBuilder } byBuilder[builderIdentity] = auth @@ -57,7 +57,7 @@ func (c *RequestAuthCache) Store(slot phase0.Slot, builderIdentity string, auth // Get returns a copy of the builder-identity → reconstructed-auth map for the proposal slot; empty // when nothing reconstructed yet. The copy is shallow: the auths are shared with the cache, with // the runner's frozen state, and across token-sharing identities — treat them as immutable. -func (c *RequestAuthCache) Get(slot phase0.Slot) map[string]*gloas.SignedRequestAuthV1 { +func (c *RequestAuthCache) Get(slot phase0.Slot) map[string]*gloas.SignedBuilderRequestAuth { c.mu.Lock() defer c.mu.Unlock() return maps.Clone(c.auths[slot]) diff --git a/protocol/v2/ssv/request_auth_cache_test.go b/protocol/v2/ssv/request_auth_cache_test.go index ba75008cd5..f7bf7fc202 100644 --- a/protocol/v2/ssv/request_auth_cache_test.go +++ b/protocol/v2/ssv/request_auth_cache_test.go @@ -12,8 +12,8 @@ import ( func TestRequestAuthCache(t *testing.T) { now := phase0.Slot(100) cache := NewRequestAuthCache(func() phase0.Slot { return now }) - authAt := func(slot phase0.Slot) *gloas.SignedRequestAuthV1 { - return &gloas.SignedRequestAuthV1{Message: &gloas.RequestAuthV1{Data: []byte("x"), Slot: slot}} + authAt := func(slot phase0.Slot) *gloas.SignedBuilderRequestAuth { + return &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte("x"), Slot: slot}} } require.Empty(t, cache.Get(110)) diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 9521d1d481..10e77861e3 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -65,11 +65,11 @@ type ProposerPreferencesRunnerOptions struct { FeeRecipientProvider feeRecipientProvider GasLimit uint64 - // Builders is the cluster's direct-builder list (issue #2962, validated at startup): for each - // authenticatable entry the slot sub-runners additionally threshold-sign a RequestAuthV1 per - // upcoming proposal slot. Empty (the default) disables the overlay entirely. - Builders []gloas.BuilderEntry - // RequestAuthCache receives each reconstructed SignedRequestAuthV1 for the §4 produce path. + // Builders is the cluster's direct-builder config (issue #2962, validated at startup): for each + // entry the slot sub-runners additionally threshold-sign a BuilderRequestAuth per upcoming + // proposal slot. Empty Entries disables the overlay entirely. + Builders gloas.BuilderConfig + // RequestAuthCache receives each reconstructed SignedBuilderRequestAuth for the §4 produce path. RequestAuthCache *ssv.RequestAuthCache } @@ -330,13 +330,13 @@ type proposerPreferencesSlotRunner struct { // stash replay re-seeds the replacement instead, our own first partial included. broadcastPreferences *gloas.ProposerPreferences - // builders is the cluster's direct-builder list (issue #2962 B1): for each authenticatable entry - // executeDuty freezes and threshold-signs a RequestAuthV1{data, proposal_slot} alongside the §5 - // preference. Empty disables the request-auth round entirely. + // builders is the cluster's direct-builder entry list (issue #2962 B1): for each entry executeDuty + // freezes and threshold-signs a BuilderRequestAuth{data, proposal_slot} alongside the §5 preference. + // Empty disables the request-auth round entirely. builders []gloas.BuilderEntry requestAuthCache *ssv.RequestAuthCache - // requestAuths maps each frozen RequestAuthV1's signing root to the object and its builders; + // requestAuths maps each frozen BuilderRequestAuth's signing root to the object and its builders; // incoming RequestAuthPartialSig messages are admitted only against these roots. nil until the // duty executes here; empty when it executed but could freeze nothing (domain-fetch failure), // so peer partials hard-fail as unknown roots instead of burning queue retries — the @@ -375,7 +375,7 @@ func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *pr operatorSigner: opts.OperatorSigner, feeRecipientProvider: opts.FeeRecipientProvider, gasLimit: opts.GasLimit, - builders: opts.Builders, + builders: opts.Builders.Entries, requestAuthCache: opts.RequestAuthCache, broadcastAuthRoots: map[[32]byte]struct{}{}, reconstructedAuthRoots: map[[32]byte]struct{}{}, diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index f76cdf0d51..ae1dc8b644 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -14,16 +14,16 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// The §5 dispatcher's request-auth rounds (issue #2962 B1): threshold-signing one RequestAuthV1 +// The §5 dispatcher's request-auth rounds (issue #2962 B1): threshold-signing one BuilderRequestAuth // per configured builder, riding the proposer-preferences duty. The per-slot auth state lives on // proposerPreferencesSlotRunner (proposer_preferences.go); this file holds the round logic. -// frozenRequestAuth pairs a frozen RequestAuthV1 with every configured builder relationship it +// frozenRequestAuth pairs a frozen BuilderRequestAuth with every configured builder relationship it // authenticates: the signing root derives from (data, slot) alone — not the URL — so distinct // entries sharing one pre-agreed token converge on one root, one broadcast, and one reconstruction // serving them all. type frozenRequestAuth struct { - auth *gloas.RequestAuthV1 + auth *gloas.BuilderRequestAuth builders []frozenBuilderRef } @@ -33,11 +33,11 @@ type frozenBuilderRef struct { url string // for logging } -// runRequestAuthRound freezes one RequestAuthV1{data, proposal_slot} per authenticatable configured -// builder, records its signing root so incoming partials can be admitted, and broadcasts this -// operator's partial — once per root, across re-emissions. Per-builder failures are logged and -// skipped, never failing the §5 duty: a builder whose auth misses quorum is simply not contactable -// for the slot, and the enshrined flow (gossip bids, self-build) stays available. +// runRequestAuthRound freezes one BuilderRequestAuth{data, proposal_slot} per configured builder, +// records its signing root so incoming partials can be admitted, and broadcasts this operator's +// partial — once per root, across re-emissions. Per-builder failures are logged and skipped, never +// failing the §5 duty: a builder whose auth misses quorum is simply not contactable for the slot, +// and the enshrined flow (gossip bids, self-build) stays available. func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, logger *zap.Logger, validatorDuty *spectypes.ValidatorDuty, proposalSlot phase0.Slot) { if len(r.builders) == 0 { return @@ -63,10 +63,7 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) continue } - if len(data) == 0 { - continue // the default (empty-URL) entry has nothing to authenticate - } - auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: data, Slot: proposalSlot} root, err := spectypes.ComputeETHSigningRoot(auth, domain) if err != nil { logger.Warn("request auth skipped: could not compute signing root", @@ -105,7 +102,7 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, } // processRequestAuthPartial collects request-auth partials into their own container and, on the -// first quorum for a root, reconstructs the builder-facing SignedRequestAuthV1 into the shared +// first quorum for a root, reconstructs the builder-facing SignedBuilderRequestAuth into the shared // cache. No succeeded-gate: the preference submission concluding the duty must not stop auth // collection, which legitimately runs until the sub-runner is evicted. func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { @@ -166,7 +163,7 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co urls = append(urls, ref.url) } if r.requestAuthCache != nil { - signed := &gloas.SignedRequestAuthV1{Message: frozen.auth, Signature: signature} + signed := &gloas.SignedBuilderRequestAuth{Message: frozen.auth, Signature: signature} for _, ref := range frozen.builders { r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) } diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index a74bd160ed..51a4bce1c8 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -30,8 +30,8 @@ func broadcastPartialSigTypes(t *testing.T, msgs []*spectypes.SignedSSVMessage) } // End-to-end request-auth convergence riding the §5 duty (issue #2962 B1): executing the duty -// freezes and broadcasts one auth partial per authenticatable builder (the default entry excluded); -// stashed peer partials replay into the round; quorum reconstructs the SignedRequestAuthV1 into the +// freezes and broadcasts one auth partial per distinct auth root (token-sharing builders share one); +// stashed peer partials replay into the round; quorum reconstructs the SignedBuilderRequestAuth into the // shared cache; re-emissions never re-broadcast (auth roots are re-emission-invariant); and a root // outside the frozen set (config divergence) is a hard error. The §5 preference flow must conclude // exactly as without builders. @@ -49,9 +49,8 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { {URL: "https://builder-a.example.com"}, // auth data defaults to the URL bytes {URL: "https://builder-b.example.com", AuthData: "0x010203"}, // explicit pre-agreed bytes {URL: "https://builder-d.example.com", AuthData: "0x010203"}, // distinct builder sharing B's token - {}, // the default entry: unsigned preferences only, no auth round } - require.NoError(t, gloas.ValidateBuilderEntries(builders)) + require.NoError(t, gloas.ValidateBuilderConfig(gloas.BuilderConfig{Entries: builders})) runnerIface, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ BaseRunnerOptions: BaseRunnerOptions{ @@ -64,7 +63,7 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { }, FeeRecipientProvider: fixedFeeRecipientProvider{addr: bellatrix.ExecutionAddress{0xfe}}, GasLimit: 36_000_000, - Builders: builders, + Builders: gloas.BuilderConfig{Entries: builders}, RequestAuthCache: cache, }) require.NoError(t, err) @@ -81,7 +80,7 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { // peerAuthPartial signs the auth every operator derives from the shared builder config, as peer opID. peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID, data []byte) *spectypes.PartialSignatureMessages { t.Helper() - auth := &gloas.RequestAuthV1{Data: data, Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: data, Slot: proposalSlot} domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) @@ -117,7 +116,7 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { require.NoError(t, disp.StartNewDuty(ctx, logger, duty, quorum)) types := broadcastPartialSigTypes(t, network.BroadcastedMsgs) require.Equal(t, 1, types[spectypes.ProposerPreferencesPartialSig]) - require.Equal(t, 2, types[spectypes.RequestAuthPartialSig], "one auth partial per distinct root; the token-sharing pair broadcasts once, the default entry signs nothing") + require.Equal(t, 2, types[spectypes.RequestAuthPartialSig], "one auth partial per distinct root; the token-sharing pair broadcasts once") auths := cache.Get(proposalSlot) require.Len(t, auths, 1, "builder A reached quorum via stash replay") @@ -186,7 +185,7 @@ func TestProposerPreferencesRunner_requestAuthWithoutBuilders(t *testing.T) { ValidatorIndex: share.ValidatorIndex, }, 3)) - auth := &gloas.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: []byte("https://builder.example.com"), Slot: proposalSlot} domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) @@ -234,7 +233,7 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. }, FeeRecipientProvider: fixedFeeRecipientProvider{addr: feeRecipient}, GasLimit: gasLimit, - Builders: builders, + Builders: gloas.BuilderConfig{Entries: builders}, RequestAuthCache: cache, }) require.NoError(t, err) @@ -284,7 +283,7 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. // Auth partials arriving after the §5 success must still be collected and reconstructed. authData := []byte("https://builder-a.example.com") - auth := &gloas.RequestAuthV1{Data: authData, Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: authData, Slot: proposalSlot} domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) @@ -334,7 +333,7 @@ func TestProposerPreferencesRunner_requestAuthSurvivesConcludedReemission(t *tes }, FeeRecipientProvider: fixedFeeRecipientProvider{addr: feeRecipient}, GasLimit: gasLimit, - Builders: builders, + Builders: gloas.BuilderConfig{Entries: builders}, RequestAuthCache: cache, }) require.NoError(t, err) @@ -377,7 +376,7 @@ func TestProposerPreferencesRunner_requestAuthSurvivesConcludedReemission(t *tes } peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID) *spectypes.PartialSignatureMessages { t.Helper() - auth := &gloas.RequestAuthV1{Data: []byte("https://builder-a.example.com"), Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: []byte("https://builder-a.example.com"), Slot: proposalSlot} domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) diff --git a/protocol/v2/ssv/validator/opts.go b/protocol/v2/ssv/validator/opts.go index 20e9e024fc..d4f9117a34 100644 --- a/protocol/v2/ssv/validator/opts.go +++ b/protocol/v2/ssv/validator/opts.go @@ -53,7 +53,7 @@ type CommonOptions struct { Graffiti []byte ProposerDelay time.Duration ProposerDelayEPBS time.Duration - Builders []gloas.BuilderEntry + Builders gloas.BuilderConfig } // NewCommonOptions finalizes a CommonOptions literal: it owns QueueSize (any caller-set value is diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 49d752db0f..4bd6568b05 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -7,76 +7,93 @@ import ( "strings" ) -// MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2) and, through +// MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2). It is SSV's own +// sub-cap of the beacon-API's MAX_BUILDER_ENTRIES (64); the tighter bound also sizes // MaxRequestAuthDistinctRoots, the wire budget that config implies. const MaxBuilderEntries = 8 -// MaxRequestAuthDistinctRoots bounds the distinct RequestAuthV1 signing roots one signer may put on -// the wire per proposal slot: exactly one per authenticatable builder entry, so the budget equals -// the entry cap. No headroom is provisioned — auth roots don't move with dependent_root, and wire -// validation is config-independent, so every extra admitted root would burden clusters that never -// opt in. The accepted cost: a restart with a changed builder list can present fresh roots to a -// slot whose budget is already spent, and the excess is IGNOREd until that slot passes -// (self-healing as the lookahead rolls). Message validation enforces the bound per (slot, signer); -// the §5 dispatcher sizes its pending stash from it. +// MaxRequestAuthDistinctRoots bounds the distinct BuilderRequestAuth signing roots one signer may put +// on the wire per proposal slot: at most one per configured entry (entries sharing auth_data share a +// root), so the budget equals the entry cap. No headroom is provisioned — auth roots don't move with +// dependent_root, and wire validation is config-independent, so every extra admitted root would +// burden clusters that never opt in. The accepted cost: a restart with a changed builder list can +// present fresh roots to a slot whose budget is already spent, and the excess is IGNOREd until that +// slot passes (self-healing as the lookahead rolls). Message validation enforces the bound per +// (slot, signer); the §5 dispatcher sizes its pending stash from it. const MaxRequestAuthDistinctRoots = MaxBuilderEntries +// defaultBuilderBoostFactor is the neutral bid multiplier (keymanager-APIs#88 / beacon-APIs#630). +const defaultBuilderBoostFactor = 100 + // BuilderIdentity is the identity of a configured builder relationship: the (URL, auth data) pair, -// per keymanager-APIs#87 (multiple entries MAY share a URL with different auth data). It keys the -// per-slot reconstructed-auth cache and the config duplicate check. +// per keymanager-APIs#88 (multiple entries MAY share a URL with different auth data; URL is compared +// exactly and auth data by decoded bytes). It keys the per-slot reconstructed-auth cache and the +// config duplicate check. func BuilderIdentity(url string, authData []byte) string { return url + "\x00" + string(authData) } -// BuilderEntry is one configured direct builder for the ePBS (Gloas) external-builder overlay -// (issue #2962), in keymanager-APIs#87's field vocabulary plus beacon-APIs#625's max_trusted_bid. +// BuilderConfig is the cluster's direct-builder configuration for the ePBS (Gloas) external-builder +// overlay (issue #2962), in keymanager-APIs#88's BuilderConfig vocabulary. The top-level MinBid and +// BuilderBoostFactor apply to p2p (gossiped) bids and, per #88, double as the default for any Entry +// that omits its own; each Entry names one builder to solicit a builder-API bid from. // -// Entries MUST be identical across ALL operators of every cluster sharing a validator: AuthData is -// threshold-signed into RequestAuthV1, so any byte divergence splits the quorum and silently -// disables that builder; the unsigned knobs steer bid selection per-operator, where divergence is -// consensus-safe but leaves the effective policy to whoever leads the round. See +// The whole config MUST be identical across ALL operators of every cluster sharing a validator: +// AuthData is threshold-signed into BuilderRequestAuth, so any byte divergence splits the quorum and +// silently disables that builder; the unsigned knobs steer bid selection per-operator, where +// divergence is consensus-safe but leaves the effective policy to whoever leads the round. See // docs/EXTERNAL_BUILDERS.md. // -// Today only URL and AuthData are consumed; the unsigned knobs take effect with the produceBlockV4 -// POST migration and track beacon-APIs#625 until it merges. +// Today only Entries' URL and AuthData are consumed (the request-auth signing round); the unsigned +// knobs and the resolution below take effect with the produceBlockV4 POST migration (beacon-APIs#630). +type BuilderConfig struct { + // MinBid is the minimum total payment (Gwei) accepted from a p2p bid, and the default for any + // Entry that omits its own MinBid. Zero means no floor. + MinBid uint64 `yaml:"MinBid"` + // BuilderBoostFactor is the percentage bid multiplier applied to p2p bids, and the default for + // any Entry that omits its own; nil is the neutral 100 (0 forces local, MaxUint64 forces the + // builder). + BuilderBoostFactor *uint64 `yaml:"BuilderBoostFactor"` + // Entries is the set of builders to solicit builder-API bids from, one BuilderEntry each. + Entries []BuilderEntry `yaml:"Entries"` +} + +// BuilderEntry is one configured direct builder, in keymanager-APIs#88's BuilderEntry vocabulary. An +// omitted MinBid or BuilderBoostFactor inherits the enclosing BuilderConfig's value (its resolution +// is EffectiveMinBid / EffectiveBoostFactor). type BuilderEntry struct { - // URL the beacon node (and, for submitBuilderPreferences, the SSV node) contacts the builder - // on. An empty URL denotes the single default entry: unsigned preferences applied to any - // contacted builder without a matching entry (beacon-APIs#625); it cannot be authenticated. + // URL the beacon node (and, for submitBuilderPreferences, the SSV node) contacts the builder on. + // Required and non-empty. URL string `yaml:"URL"` - // AuthData is the 0x-hex form of the exact bytes signed into RequestAuthV1.Data — the token + // AuthData is the 0x-hex form of the exact bytes signed into BuilderRequestAuth.Data — the token // agreed with the builder out of band. When omitted it defaults to the UTF-8 bytes of URL, // exactly as configured (the builder-specs default; no canonicalization anywhere). AuthData string `yaml:"AuthData"` - // BuilderBoostFactor is the percentage multiplier applied to this builder's bid value when the - // beacon node chooses between builder bids and the local payload; nil defaults to the neutral - // 100 (0 forces local, MaxUint64 forces the builder — beacon-APIs#625). - BuilderBoostFactor *uint64 `yaml:"BuilderBoostFactor"` - // MaxTrustedBid caps, in Gwei, how much of this builder's bid value the beacon node may trust - // off-protocol (beacon-APIs#625). - MaxTrustedBid uint64 `yaml:"MaxTrustedBid"` - // MinBid is the minimum bid value in Gwei below which this builder's bids are ignored in favor - // of the local payload (beacon-APIs#625). - MinBid uint64 `yaml:"MinBid"` - // MaxExecutionPayment caps, in Gwei, the execution-layer (trusted, off-protocol) payment - // accepted from this builder; submitted via submitBuilderPreferences and used as the local - // backstop when validating bids (builder-specs). + // BuilderPubKeys optionally pins the BLS public keys (0x-hex) that bids from this builder must be + // signed with (keymanager-APIs#88). Empty accepts a bid from any builder. + BuilderPubKeys []string `yaml:"BuilderPubKeys"` + // MaxExecutionPayment caps, in Gwei, the execution-layer (trusted, off-protocol) payment accepted + // from this builder; submitted via submitBuilderPreferences and the local backstop when valuing + // bids (builder-specs). MaxExecutionPayment uint64 `yaml:"MaxExecutionPayment"` - // PubKey optionally pins the BLS public key bids from this builder must be signed with - // (keymanager-APIs#87), 0x-hex. - PubKey string `yaml:"PubKey"` + // MinBid is the minimum bid value (Gwei) below which this builder's bids are ignored in favor of + // the local payload; nil inherits the enclosing BuilderConfig's MinBid. + MinBid *uint64 `yaml:"MinBid"` + // BuilderBoostFactor is the percentage bid multiplier for this builder; nil inherits the + // enclosing BuilderConfig's BuilderBoostFactor. + BuilderBoostFactor *uint64 `yaml:"BuilderBoostFactor"` } -// defaultBuilderBoostFactor is the neutral bid multiplier (beacon-APIs#625). -const defaultBuilderBoostFactor = 100 - -// IsDefault reports whether this is the empty-URL default entry (unsigned preferences for any -// contacted builder without a matching entry). -func (e *BuilderEntry) IsDefault() bool { return e.URL == "" } +// EffectiveBoostFactor resolves the config-level boost factor, defaulting to the neutral 100. +func (c *BuilderConfig) EffectiveBoostFactor() uint64 { + if c.BuilderBoostFactor == nil { + return defaultBuilderBoostFactor + } + return *c.BuilderBoostFactor +} -// AuthDataBytes returns the exact bytes signed into RequestAuthV1.Data for this builder: the -// decoded AuthData, or the UTF-8 bytes of URL when AuthData is omitted. The default entry yields -// no bytes (it cannot be authenticated). +// AuthDataBytes returns the exact bytes signed into BuilderRequestAuth.Data for this builder: the +// decoded AuthData, or the UTF-8 bytes of URL when AuthData is omitted. func (e *BuilderEntry) AuthDataBytes() ([]byte, error) { if e.AuthData == "" { return []byte(e.URL), nil @@ -85,74 +102,72 @@ func (e *BuilderEntry) AuthDataBytes() ([]byte, error) { if err != nil { return nil, fmt.Errorf("invalid AuthData hex: %w", err) } - if len(b) > MaxRequestAuthDataSize { - return nil, fmt.Errorf("AuthData is %d bytes, exceeding the %d limit", len(b), MaxRequestAuthDataSize) + if len(b) > MaxBuilderAuthDataSize { + return nil, fmt.Errorf("AuthData is %d bytes, exceeding the %d limit", len(b), MaxBuilderAuthDataSize) } return b, nil } -// EffectiveBoostFactor resolves the configured boost factor, defaulting to the neutral 100. -func (e *BuilderEntry) EffectiveBoostFactor() uint64 { - if e.BuilderBoostFactor == nil { - return defaultBuilderBoostFactor +// EffectiveMinBid resolves this entry's MinBid, inheriting the config default (keymanager-APIs#88) +// when unset. +func (e *BuilderEntry) EffectiveMinBid(cfg *BuilderConfig) uint64 { + if e.MinBid != nil { + return *e.MinBid } - return *e.BuilderBoostFactor + return cfg.MinBid } -// ValidateBuilderEntries checks a configured builder list: entry cap, at most one default -// (empty-URL) entry carrying no AuthData, parseable http(s) URLs, decodable within-limit auth -// data, no duplicate (URL, auth data) identities (multiple entries MAY share a URL with different -// auth data), and well-formed optional pubkeys. The property that matters most — every operator of -// every shared cluster holding the identical list — cannot be checked here and stays an -// operational requirement (docs/EXTERNAL_BUILDERS.md). -func ValidateBuilderEntries(entries []BuilderEntry) error { - if len(entries) > MaxBuilderEntries { - return fmt.Errorf("%d builder entries exceed the %d limit", len(entries), MaxBuilderEntries) +// EffectiveBoostFactor resolves this entry's boost factor, inheriting the config default +// (keymanager-APIs#88) when unset — which itself defaults to the neutral 100. +func (e *BuilderEntry) EffectiveBoostFactor(cfg *BuilderConfig) uint64 { + if e.BuilderBoostFactor != nil { + return *e.BuilderBoostFactor } - seen := make(map[string]struct{}, len(entries)) - haveDefault := false - for i := range entries { - e := &entries[i] - if e.IsDefault() { - if haveDefault { - return fmt.Errorf("builder entry %d: at most one default (empty-URL) entry is allowed", i) - } - haveDefault = true - if e.AuthData != "" { - return fmt.Errorf("builder entry %d: the default (empty-URL) entry cannot carry AuthData — there is no single builder to authenticate to", i) - } - } else { - u, err := url.Parse(e.URL) - if err != nil { - return fmt.Errorf("builder entry %d: invalid URL: %w", i, err) - } - if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) - } - // The URL's bytes are signed only when they serve as the default auth data. - if e.AuthData == "" && len(e.URL) > MaxRequestAuthDataSize { - return fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxRequestAuthDataSize) - } + return cfg.EffectiveBoostFactor() +} + +// ValidateBuilderConfig checks a configured builder set: entry cap, non-empty parseable http(s) +// URLs, decodable within-limit auth data, no duplicate (URL, auth data) identities (multiple entries +// MAY share a URL with different auth data), and well-formed optional builder pubkeys. The property +// that matters most — every operator of every shared cluster holding the identical config — cannot +// be checked here and stays an operational requirement (docs/EXTERNAL_BUILDERS.md). +func ValidateBuilderConfig(cfg BuilderConfig) error { + if len(cfg.Entries) > MaxBuilderEntries { + return fmt.Errorf("%d builder entries exceed the %d limit", len(cfg.Entries), MaxBuilderEntries) + } + seen := make(map[string]struct{}, len(cfg.Entries)) + for i := range cfg.Entries { + e := &cfg.Entries[i] + u, err := url.Parse(e.URL) + if err != nil { + return fmt.Errorf("builder entry %d: invalid URL: %w", i, err) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) + } + // The URL's bytes are signed when they serve as the default auth data. + if e.AuthData == "" && len(e.URL) > MaxBuilderAuthDataSize { + return fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxBuilderAuthDataSize) } data, err := e.AuthDataBytes() if err != nil { return fmt.Errorf("builder entry %d: %w", i, err) } - if !e.IsDefault() && len(data) == 0 { - return fmt.Errorf("builder entry %d: explicitly empty AuthData — omit the field to default to the URL bytes", i) + if len(data) == 0 { + return fmt.Errorf("builder entry %d: AuthData decodes to zero bytes — omit it to default to the URL bytes", i) } identity := BuilderIdentity(e.URL, data) if _, dup := seen[identity]; dup { return fmt.Errorf("builder entry %d: duplicate (URL, AuthData) identity", i) } seen[identity] = struct{}{} - if e.PubKey != "" { - pk, err := hex.DecodeString(strings.TrimPrefix(e.PubKey, "0x")) + for j, pk := range e.BuilderPubKeys { + b, err := hex.DecodeString(strings.TrimPrefix(pk, "0x")) if err != nil { - return fmt.Errorf("builder entry %d: invalid PubKey hex: %w", i, err) + return fmt.Errorf("builder entry %d: BuilderPubKeys[%d]: invalid hex: %w", i, j, err) } - if len(pk) != 48 { - return fmt.Errorf("builder entry %d: PubKey must be 48 bytes, got %d", i, len(pk)) + if len(b) != 48 { + return fmt.Errorf("builder entry %d: BuilderPubKeys[%d]: must be 48 bytes, got %d", i, j, len(b)) } } } diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index 1764bd15d7..7a1fae7c72 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -21,80 +21,73 @@ func TestBuilderEntry_AuthDataBytes(t *testing.T) { require.NoError(t, err) require.Equal(t, []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, b) - // The default entry yields no bytes. - b, err = (&BuilderEntry{}).AuthDataBytes() - require.NoError(t, err) - require.Empty(t, b) - _, err = (&BuilderEntry{URL: "https://x.example", AuthData: "0xzz"}).AuthDataBytes() require.ErrorContains(t, err, "invalid AuthData hex") - _, err = (&BuilderEntry{URL: "https://x.example", AuthData: "0x" + strings.Repeat("00", MaxRequestAuthDataSize+1)}).AuthDataBytes() + _, err = (&BuilderEntry{URL: "https://x.example", AuthData: "0x" + strings.Repeat("00", MaxBuilderAuthDataSize+1)}).AuthDataBytes() require.ErrorContains(t, err, "exceeding") } -func TestBuilderEntry_EffectiveBoostFactor(t *testing.T) { - require.Equal(t, uint64(100), (&BuilderEntry{}).EffectiveBoostFactor()) - zero := uint64(0) - require.Equal(t, uint64(0), (&BuilderEntry{BuilderBoostFactor: &zero}).EffectiveBoostFactor()) +func TestBuilderEntry_Effective(t *testing.T) { + // Config-level boost factor defaults to the neutral 100; an entry inherits it when unset. + empty := &BuilderConfig{} + require.Equal(t, uint64(100), empty.EffectiveBoostFactor()) + require.Equal(t, uint64(100), (&BuilderEntry{}).EffectiveBoostFactor(empty)) + require.Equal(t, uint64(0), (&BuilderEntry{}).EffectiveMinBid(empty)) + + // Entry values, when set, win over the config default (including an explicit zero). + zero, seven, nine := uint64(0), uint64(7), uint64(9) + cfg := &BuilderConfig{MinBid: 5, BuilderBoostFactor: &nine} + require.Equal(t, uint64(9), cfg.EffectiveBoostFactor()) + require.Equal(t, uint64(0), (&BuilderEntry{BuilderBoostFactor: &zero}).EffectiveBoostFactor(cfg)) + require.Equal(t, uint64(7), (&BuilderEntry{MinBid: &seven}).EffectiveMinBid(cfg)) + + // An entry that omits its own inherits the config's (keymanager-APIs#88 resolution). + require.Equal(t, uint64(9), (&BuilderEntry{}).EffectiveBoostFactor(cfg)) + require.Equal(t, uint64(5), (&BuilderEntry{}).EffectiveMinBid(cfg)) } -func TestValidateBuilderEntries(t *testing.T) { - valid := []BuilderEntry{ - {URL: "https://builder-a.example.com"}, - {URL: "https://builder-b.example.com", AuthData: "0x0102"}, - // Same URL, different auth data — a distinct identity per keymanager-APIs#87. - {URL: "https://builder-b.example.com", AuthData: "0x0304"}, - {}, // the default entry +func TestValidateBuilderConfig(t *testing.T) { + validate := func(entries ...BuilderEntry) error { + return ValidateBuilderConfig(BuilderConfig{Entries: entries}) } - require.NoError(t, ValidateBuilderEntries(valid)) - require.NoError(t, ValidateBuilderEntries(nil)) + + require.NoError(t, validate( + BuilderEntry{URL: "https://builder-a.example.com"}, + BuilderEntry{URL: "https://builder-b.example.com", AuthData: "0x0102"}, + // Same URL, different auth data — a distinct identity per keymanager-APIs#88. + BuilderEntry{URL: "https://builder-b.example.com", AuthData: "0x0304"}, + )) + require.NoError(t, ValidateBuilderConfig(BuilderConfig{})) require.ErrorContains(t, - ValidateBuilderEntries(make([]BuilderEntry, MaxBuilderEntries+1)), + ValidateBuilderConfig(BuilderConfig{Entries: make([]BuilderEntry, MaxBuilderEntries+1)}), "exceed") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{}, {}}), - "at most one default") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{AuthData: "0x01"}}), - "cannot carry AuthData") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "ftp://builder.example.com"}}), - "must be http(s)") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "https://"}}), - "must be http(s)") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", AuthData: "0x"}}), - "explicitly empty AuthData") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{ - {URL: "https://x.example"}, - {URL: "https://x.example"}, - }), - "duplicate") + // A non-empty http(s) URL is required (no empty-URL "default" entry any more). + require.ErrorContains(t, validate(BuilderEntry{}), "must be http(s)") + require.ErrorContains(t, validate(BuilderEntry{URL: "ftp://builder.example.com"}), "must be http(s)") + require.ErrorContains(t, validate(BuilderEntry{URL: "https://"}), "must be http(s)") + require.ErrorContains(t, validate(BuilderEntry{URL: "https://x.example", AuthData: "0x"}), "zero bytes") + require.ErrorContains(t, validate( + BuilderEntry{URL: "https://x.example"}, + BuilderEntry{URL: "https://x.example"}, + ), "duplicate") // Same identity via explicit auth data equal to another entry's URL-derived default. - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{ - {URL: "https://x.example"}, - {URL: "https://x.example", AuthData: "0x" + hex.EncodeToString([]byte("https://x.example"))}, - }), - "duplicate") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x01"}}), - "PubKey must be 48 bytes") - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0xzz"}}), - "invalid PubKey hex") - require.NoError(t, - ValidateBuilderEntries([]BuilderEntry{{URL: "https://x.example", PubKey: "0x" + strings.Repeat("ab", 48)}})) + require.ErrorContains(t, validate( + BuilderEntry{URL: "https://x.example"}, + BuilderEntry{URL: "https://x.example", AuthData: "0x" + hex.EncodeToString([]byte("https://x.example"))}, + ), "duplicate") + + // BuilderPubKeys is a list; each must be 48-byte 0x-hex; empty accepts any builder. + require.ErrorContains(t, validate(BuilderEntry{URL: "https://x.example", BuilderPubKeys: []string{"0x01"}}), "48 bytes") + require.ErrorContains(t, validate(BuilderEntry{URL: "https://x.example", BuilderPubKeys: []string{"0xzz"}}), "invalid hex") + require.NoError(t, validate(BuilderEntry{ + URL: "https://x.example", + BuilderPubKeys: []string{"0x" + strings.Repeat("ab", 48), "0x" + strings.Repeat("cd", 48)}, + })) // A URL longer than the auth-data limit only matters when its bytes ARE the auth data. - longURL := "https://x.example/" + strings.Repeat("a", MaxRequestAuthDataSize) - require.ErrorContains(t, - ValidateBuilderEntries([]BuilderEntry{{URL: longURL}}), - "exceeding") - require.NoError(t, - ValidateBuilderEntries([]BuilderEntry{{URL: longURL, AuthData: "0x0102"}})) + longURL := "https://x.example/" + strings.Repeat("a", MaxBuilderAuthDataSize) + require.ErrorContains(t, validate(BuilderEntry{URL: longURL}), "exceeding") + require.NoError(t, validate(BuilderEntry{URL: longURL, AuthData: "0x0102"})) } diff --git a/protocol/v2/types/gloas/request_auth.go b/protocol/v2/types/gloas/request_auth.go index 7cb7768d7c..05d4e0f513 100644 --- a/protocol/v2/types/gloas/request_auth.go +++ b/protocol/v2/types/gloas/request_auth.go @@ -13,47 +13,48 @@ import ( // Regenerate with `go generate ./...`. The phase0 --include is resolved from the module graph // (`go list -m`), so it tracks go-eth2-client across dependency bumps rather than pinning. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./request_auth.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs RequestAuthV1,SignedRequestAuthV1" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path ./request_auth.go --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0 --objs BuilderRequestAuth,SignedBuilderRequestAuth" -// MaxRequestAuthDataSize is builder-specs' MAX_DATA_SIZE: the ByteList limit of RequestAuthV1.Data. -const MaxRequestAuthDataSize = 4096 +// MaxBuilderAuthDataSize is builder-specs' MAX_BUILDER_AUTH_DATA_SIZE: the ByteList limit of BuilderRequestAuth.Data. +const MaxBuilderAuthDataSize = 4096 -// RequestAuthV1 is builder-specs' request-authentication message: Data is the opaque per-builder +// BuilderRequestAuth is builder-specs' request-authentication message: Data is the opaque per-builder // token agreed out of band (defaulting to the UTF-8 bytes of the builder's advertised URL, exactly // as advertised — never canonicalized, signed exactly as serialized), and Slot is the proposal slot // the request is authorized for, not the slot at which it is signed or sent. One signed auth covers // both builder channels (getExecutionPayloadBid and submitBuilderPreferences). Signed under -// DomainRequestAuth — genesis-style compute_domain, never fork-versioned. Variable-size SSZ. -type RequestAuthV1 struct { +// DomainRequestAuth — a genesis-style compute_domain (fork-agnostic); the wire type is nonetheless +// fork-versioned for decoding, so hops carrying the body set Eth-Consensus-Version. Variable-size SSZ. +type BuilderRequestAuth struct { Data []byte `ssz-max:"4096"` Slot phase0.Slot } -// SignedRequestAuthV1 is a RequestAuthV1 plus the validator's signature, carried in builder-API +// SignedBuilderRequestAuth is a BuilderRequestAuth plus the validator's signature, carried in builder-API // request bodies (and forwarded byte-for-byte unchanged by every hop). Variable-size SSZ. -type SignedRequestAuthV1 struct { - Message *RequestAuthV1 +type SignedBuilderRequestAuth struct { + Message *BuilderRequestAuth Signature phase0.BLSSignature `ssz-size:"96"` } -// requestAuthJSON is the builder-API JSON form: uint64 as a decimal string, data as 0x-hex, per +// builderRequestAuthJSON is the builder-API JSON form: uint64 as a decimal string, data as 0x-hex, per // go-eth2-client conventions. -type requestAuthJSON struct { +type builderRequestAuthJSON struct { Data string `json:"data"` Slot string `json:"slot"` } // MarshalJSON implements json.Marshaler. -func (r *RequestAuthV1) MarshalJSON() ([]byte, error) { - return json.Marshal(&requestAuthJSON{ +func (r *BuilderRequestAuth) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderRequestAuthJSON{ Data: fmt.Sprintf("%#x", r.Data), Slot: fmt.Sprintf("%d", r.Slot), }) } // UnmarshalJSON implements json.Unmarshaler. -func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { - var data requestAuthJSON +func (r *BuilderRequestAuth) UnmarshalJSON(input []byte) error { + var data builderRequestAuthJSON if err := json.Unmarshal(input, &data); err != nil { return fmt.Errorf("invalid JSON: %w", err) } @@ -61,8 +62,8 @@ func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { if err != nil { return fmt.Errorf("invalid value for data: %w", err) } - if len(b) > MaxRequestAuthDataSize { - return fmt.Errorf("incorrect length for data: %d bytes exceeds the %d limit", len(b), MaxRequestAuthDataSize) + if len(b) > MaxBuilderAuthDataSize { + return fmt.Errorf("incorrect length for data: %d bytes exceeds the %d limit", len(b), MaxBuilderAuthDataSize) } r.Data = b slot, err := strconv.ParseUint(data.Slot, 10, 64) @@ -73,23 +74,23 @@ func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { return nil } -// signedRequestAuthJSON is the builder-API JSON form of SignedRequestAuthV1. -type signedRequestAuthJSON struct { - Message *RequestAuthV1 `json:"message"` - Signature string `json:"signature"` +// signedBuilderRequestAuthJSON is the builder-API JSON form of SignedBuilderRequestAuth. +type signedBuilderRequestAuthJSON struct { + Message *BuilderRequestAuth `json:"message"` + Signature string `json:"signature"` } // MarshalJSON implements json.Marshaler. -func (s *SignedRequestAuthV1) MarshalJSON() ([]byte, error) { - return json.Marshal(&signedRequestAuthJSON{ +func (s *SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedBuilderRequestAuthJSON{ Message: s.Message, Signature: fmt.Sprintf("%#x", s.Signature), }) } // UnmarshalJSON implements json.Unmarshaler. -func (s *SignedRequestAuthV1) UnmarshalJSON(input []byte) error { - var data signedRequestAuthJSON +func (s *SignedBuilderRequestAuth) UnmarshalJSON(input []byte) error { + var data signedBuilderRequestAuthJSON if err := json.Unmarshal(input, &data); err != nil { return fmt.Errorf("invalid JSON: %w", err) } diff --git a/protocol/v2/types/gloas/request_auth_encoding.go b/protocol/v2/types/gloas/request_auth_encoding.go index 072455420c..f5ab9a03ad 100644 --- a/protocol/v2/types/gloas/request_auth_encoding.go +++ b/protocol/v2/types/gloas/request_auth_encoding.go @@ -8,13 +8,13 @@ import ( ssz "github.com/ferranbt/fastssz" ) -// MarshalSSZ ssz marshals the RequestAuthV1 object -func (r *RequestAuthV1) MarshalSSZ() ([]byte, error) { +// MarshalSSZ ssz marshals the BuilderRequestAuth object +func (r *BuilderRequestAuth) MarshalSSZ() ([]byte, error) { return ssz.MarshalSSZ(r) } -// MarshalSSZTo ssz marshals the RequestAuthV1 object to a target array -func (r *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { +// MarshalSSZTo ssz marshals the BuilderRequestAuth object to a target array +func (r *BuilderRequestAuth) MarshalSSZTo(buf []byte) (dst []byte, err error) { dst = buf offset := int(12) @@ -26,7 +26,7 @@ func (r *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { // Field (0) 'Data' if size := len(r.Data); size > 4096 { - err = ssz.ErrBytesLengthFn("RequestAuthV1.Data", size, 4096) + err = ssz.ErrBytesLengthFn("BuilderRequestAuth.Data", size, 4096) return } dst = append(dst, r.Data...) @@ -34,8 +34,8 @@ func (r *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { return } -// UnmarshalSSZ ssz unmarshals the RequestAuthV1 object -func (r *RequestAuthV1) UnmarshalSSZ(buf []byte) error { +// UnmarshalSSZ ssz unmarshals the BuilderRequestAuth object +func (r *BuilderRequestAuth) UnmarshalSSZ(buf []byte) error { var err error size := uint64(len(buf)) if size < 12 { @@ -71,8 +71,8 @@ func (r *RequestAuthV1) UnmarshalSSZ(buf []byte) error { return err } -// SizeSSZ returns the ssz encoded size in bytes for the RequestAuthV1 object -func (r *RequestAuthV1) SizeSSZ() (size int) { +// SizeSSZ returns the ssz encoded size in bytes for the BuilderRequestAuth object +func (r *BuilderRequestAuth) SizeSSZ() (size int) { size = 12 // Field (0) 'Data' @@ -81,13 +81,13 @@ func (r *RequestAuthV1) SizeSSZ() (size int) { return } -// HashTreeRoot ssz hashes the RequestAuthV1 object -func (r *RequestAuthV1) HashTreeRoot() ([32]byte, error) { +// HashTreeRoot ssz hashes the BuilderRequestAuth object +func (r *BuilderRequestAuth) HashTreeRoot() ([32]byte, error) { return ssz.HashWithDefaultHasher(r) } -// HashTreeRootWith ssz hashes the RequestAuthV1 object with a hasher -func (r *RequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { +// HashTreeRootWith ssz hashes the BuilderRequestAuth object with a hasher +func (r *BuilderRequestAuth) HashTreeRootWith(hh ssz.HashWalker) (err error) { indx := hh.Index() // Field (0) 'Data' @@ -109,18 +109,18 @@ func (r *RequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { return } -// GetTree ssz hashes the RequestAuthV1 object -func (r *RequestAuthV1) GetTree() (*ssz.Node, error) { +// GetTree ssz hashes the BuilderRequestAuth object +func (r *BuilderRequestAuth) GetTree() (*ssz.Node, error) { return ssz.ProofTree(r) } -// MarshalSSZ ssz marshals the SignedRequestAuthV1 object -func (s *SignedRequestAuthV1) MarshalSSZ() ([]byte, error) { +// MarshalSSZ ssz marshals the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) MarshalSSZ() ([]byte, error) { return ssz.MarshalSSZ(s) } -// MarshalSSZTo ssz marshals the SignedRequestAuthV1 object to a target array -func (s *SignedRequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { +// MarshalSSZTo ssz marshals the SignedBuilderRequestAuth object to a target array +func (s *SignedBuilderRequestAuth) MarshalSSZTo(buf []byte) (dst []byte, err error) { dst = buf offset := int(100) @@ -138,8 +138,8 @@ func (s *SignedRequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { return } -// UnmarshalSSZ ssz unmarshals the SignedRequestAuthV1 object -func (s *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) error { +// UnmarshalSSZ ssz unmarshals the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) UnmarshalSSZ(buf []byte) error { var err error size := uint64(len(buf)) if size < 100 { @@ -165,7 +165,7 @@ func (s *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) error { { buf = tail[o0:] if s.Message == nil { - s.Message = new(RequestAuthV1) + s.Message = new(BuilderRequestAuth) } if err = s.Message.UnmarshalSSZ(buf); err != nil { return err @@ -174,26 +174,26 @@ func (s *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) error { return err } -// SizeSSZ returns the ssz encoded size in bytes for the SignedRequestAuthV1 object -func (s *SignedRequestAuthV1) SizeSSZ() (size int) { +// SizeSSZ returns the ssz encoded size in bytes for the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) SizeSSZ() (size int) { size = 100 // Field (0) 'Message' if s.Message == nil { - s.Message = new(RequestAuthV1) + s.Message = new(BuilderRequestAuth) } size += s.Message.SizeSSZ() return } -// HashTreeRoot ssz hashes the SignedRequestAuthV1 object -func (s *SignedRequestAuthV1) HashTreeRoot() ([32]byte, error) { +// HashTreeRoot ssz hashes the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) HashTreeRoot() ([32]byte, error) { return ssz.HashWithDefaultHasher(s) } -// HashTreeRootWith ssz hashes the SignedRequestAuthV1 object with a hasher -func (s *SignedRequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { +// HashTreeRootWith ssz hashes the SignedBuilderRequestAuth object with a hasher +func (s *SignedBuilderRequestAuth) HashTreeRootWith(hh ssz.HashWalker) (err error) { indx := hh.Index() // Field (0) 'Message' @@ -208,7 +208,7 @@ func (s *SignedRequestAuthV1) HashTreeRootWith(hh ssz.HashWalker) (err error) { return } -// GetTree ssz hashes the SignedRequestAuthV1 object -func (s *SignedRequestAuthV1) GetTree() (*ssz.Node, error) { +// GetTree ssz hashes the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) GetTree() (*ssz.Node, error) { return ssz.ProofTree(s) } diff --git a/protocol/v2/types/gloas/request_auth_test.go b/protocol/v2/types/gloas/request_auth_test.go index c25b346259..ae06602420 100644 --- a/protocol/v2/types/gloas/request_auth_test.go +++ b/protocol/v2/types/gloas/request_auth_test.go @@ -18,8 +18,8 @@ const builderSpecsSignedRequestAuthJSON = `{ "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" }` -func TestRequestAuthV1_SSZ(t *testing.T) { - r := &RequestAuthV1{ +func TestBuilderRequestAuth_SSZ(t *testing.T) { + r := &BuilderRequestAuth{ Data: []byte("https://builder.example.com"), Slot: 42, } @@ -30,7 +30,7 @@ func TestRequestAuthV1_SSZ(t *testing.T) { require.NoError(t, err) require.Len(t, b, 12+len(r.Data)) - var dec RequestAuthV1 + var dec BuilderRequestAuth require.NoError(t, dec.UnmarshalSSZ(b)) require.Equal(t, r, &dec) @@ -41,35 +41,35 @@ func TestRequestAuthV1_SSZ(t *testing.T) { require.Equal(t, htr1, htr2) } -func TestRequestAuthV1_SSZ_DataLimit(t *testing.T) { +func TestBuilderRequestAuth_SSZ_DataLimit(t *testing.T) { // At the ByteList limit both directions succeed. - atLimit := &RequestAuthV1{Data: make([]byte, MaxRequestAuthDataSize), Slot: 1} + atLimit := &BuilderRequestAuth{Data: make([]byte, MaxBuilderAuthDataSize), Slot: 1} b, err := atLimit.MarshalSSZ() require.NoError(t, err) - var dec RequestAuthV1 + var dec BuilderRequestAuth require.NoError(t, dec.UnmarshalSSZ(b)) - require.Len(t, dec.Data, MaxRequestAuthDataSize) + require.Len(t, dec.Data, MaxBuilderAuthDataSize) // One byte over: marshal of the oversize object and unmarshal of an oversize tail both fail. - over := &RequestAuthV1{Data: make([]byte, MaxRequestAuthDataSize+1), Slot: 1} + over := &BuilderRequestAuth{Data: make([]byte, MaxBuilderAuthDataSize+1), Slot: 1} _, err = over.MarshalSSZ() require.Error(t, err) oversize := append(b, 0x00) require.Error(t, dec.UnmarshalSSZ(oversize)) } -// TestRequestAuthV1_HashTreeRoot_Golden pins the merkleization against roots computed with an +// TestBuilderRequestAuth_HashTreeRoot_Golden pins the merkleization against roots computed with an // independent implementation (plain SHA-256 chunk merkleization of the builder-specs layout: // ByteList[4096] → 128 chunk leaves + length mix-in, then 2-leaf container). -func TestRequestAuthV1_HashTreeRoot_Golden(t *testing.T) { - auth := &RequestAuthV1{Data: []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, Slot: 1} +func TestBuilderRequestAuth_HashTreeRoot_Golden(t *testing.T) { + auth := &BuilderRequestAuth{Data: []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, Slot: 1} htr, err := auth.HashTreeRoot() require.NoError(t, err) require.Equal(t, "0x3d6f2e216a08828a2bc2c9644edacd56fef00f2be824abc49f6d1a28569bd880", phase0.Root(htr).String()) - empty := &RequestAuthV1{Slot: 1} + empty := &BuilderRequestAuth{Slot: 1} htr, err = empty.HashTreeRoot() require.NoError(t, err) require.Equal(t, @@ -77,9 +77,9 @@ func TestRequestAuthV1_HashTreeRoot_Golden(t *testing.T) { phase0.Root(htr).String()) } -func TestSignedRequestAuthV1_SSZ(t *testing.T) { - s := &SignedRequestAuthV1{ - Message: &RequestAuthV1{Data: []byte("token"), Slot: 9}, +func TestSignedBuilderRequestAuth_SSZ(t *testing.T) { + s := &SignedBuilderRequestAuth{ + Message: &BuilderRequestAuth{Data: []byte("token"), Slot: 9}, Signature: phase0.BLSSignature{0xbb, 0xcc}, } // 4-byte message offset + 96-byte signature + message tail. @@ -88,15 +88,15 @@ func TestSignedRequestAuthV1_SSZ(t *testing.T) { b, err := s.MarshalSSZ() require.NoError(t, err) - var dec SignedRequestAuthV1 + var dec SignedBuilderRequestAuth require.NoError(t, dec.UnmarshalSSZ(b)) require.Equal(t, s, &dec) } -// TestSignedRequestAuthV1_BuilderSpecsExample decodes the builder-specs wire example and pins both +// TestSignedBuilderRequestAuth_BuilderSpecsExample decodes the builder-specs wire example and pins both // the field values and the hash tree root (computed with an independent implementation). -func TestSignedRequestAuthV1_BuilderSpecsExample(t *testing.T) { - var s SignedRequestAuthV1 +func TestSignedBuilderRequestAuth_BuilderSpecsExample(t *testing.T) { + var s SignedBuilderRequestAuth require.NoError(t, json.Unmarshal([]byte(builderSpecsSignedRequestAuthJSON), &s)) require.Equal(t, []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}, s.Message.Data) require.Equal(t, phase0.Slot(1), s.Message.Slot) @@ -113,31 +113,31 @@ func TestSignedRequestAuthV1_BuilderSpecsExample(t *testing.T) { require.JSONEq(t, builderSpecsSignedRequestAuthJSON, string(out)) } -func TestRequestAuthV1_JSON(t *testing.T) { - r := &RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 123} +func TestBuilderRequestAuth_JSON(t *testing.T) { + r := &BuilderRequestAuth{Data: []byte("https://builder.example.com"), Slot: 123} out, err := json.Marshal(r) require.NoError(t, err) - var dec RequestAuthV1 + var dec BuilderRequestAuth require.NoError(t, json.Unmarshal(out, &dec)) require.Equal(t, r, &dec) // Empty data round-trips as "0x". - var empty RequestAuthV1 + var empty BuilderRequestAuth require.NoError(t, json.Unmarshal([]byte(`{"data":"0x","slot":"7"}`), &empty)) require.Empty(t, empty.Data) require.Equal(t, phase0.Slot(7), empty.Slot) // Oversize data, malformed hex, and malformed slot are rejected. - oversize := make([]byte, MaxRequestAuthDataSize+1) - oversizeJSON, err := json.Marshal(&RequestAuthV1{Data: oversize}) + oversize := make([]byte, MaxBuilderAuthDataSize+1) + oversizeJSON, err := json.Marshal(&BuilderRequestAuth{Data: oversize}) require.NoError(t, err) require.ErrorContains(t, json.Unmarshal(oversizeJSON, &dec), "incorrect length for data") require.ErrorContains(t, json.Unmarshal([]byte(`{"data":"0xzz","slot":"1"}`), &dec), "invalid value for data") require.ErrorContains(t, json.Unmarshal([]byte(`{"data":"0x00","slot":"x"}`), &dec), "invalid value for slot") } -func TestSignedRequestAuthV1_JSON_MessageMissing(t *testing.T) { - var s SignedRequestAuthV1 +func TestSignedBuilderRequestAuth_JSON_MessageMissing(t *testing.T) { + var s SignedBuilderRequestAuth require.ErrorContains(t, json.Unmarshal([]byte(`{"signature":"0x00"}`), &s), "message missing") } diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index f386a18f72..9d60e27e3d 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -290,7 +290,7 @@ func (km *LocalKeyManager) signBeaconObject( // Gloas (ePBS) domains — a plain BLS signature over the SSZ root, no slashing protection (none is // in the slashing predicate): DomainPTCAttester (PTC payload attestation), DomainProposerPreferences // (proposer preferences), DomainBeaconBuilder (§6 blinded execution-payload envelope), and - // DomainRequestAuth (builder-specs RequestAuthV1, the direct-builder request auth). + // DomainRequestAuth (builder-specs BuilderRequestAuth, the direct-builder request auth). return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index 1d2319751a..86a2588d1e 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -422,7 +422,7 @@ func (km *RemoteKeyManager) prepareSignRequest( // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type. return web3signer.SignRequest{}, phase0.Root{}, errors.New("execution payload envelope signing is not supported by the remote signer: Web3Signer has no envelope type, use local signing for self-build envelopes") case spectypes.DomainRequestAuth: - // The Gloas (ePBS) direct-builder request auth (builder-specs RequestAuthV1, issue #2962) has no + // The Gloas (ePBS) direct-builder request auth (builder-specs BuilderRequestAuth, issue #2962) has no // Web3Signer request type, so a remote-signing operator can't contribute auth partials. Bounded // (the cluster reconstructs while ≤ f operators are remote-signing), but those operators must // sign locally for the direct-builder overlay to keep its full fault tolerance. From a660a041d96dfcdcf981e4486e0e4d0bb8c550b5 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 20:10:43 +0300 Subject: [PATCH 129/150] gloas: adopt the renamed spectypes.DomainBuilderRequestAuth Follow ssv-spec's DomainRequestAuth -> DomainBuilderRequestAuth rename for builder-specs parity: bump both go.mods to the ssv-spec commit and update the references and doc comments. The 0x0B000001 value is unchanged. --- beacon/goclient/signing.go | 4 ++-- go.mod | 2 +- go.sum | 2 ++ .../v2/ssv/runner/proposer_preferences_request_auth.go | 8 ++++---- protocol/v2/ssv/runner/request_auth_test.go | 8 ++++---- protocol/v2/types/gloas/request_auth.go | 5 +++-- ssvsigner/ekm/local_key_manager.go | 4 ++-- ssvsigner/ekm/local_key_manager_test.go | 2 +- ssvsigner/ekm/remote_key_manager.go | 2 +- ssvsigner/go.mod | 2 +- ssvsigner/go.sum | 2 ++ 11 files changed, 23 insertions(+), 18 deletions(-) diff --git a/beacon/goclient/signing.go b/beacon/goclient/signing.go index b63240e235..b1e872d3d6 100644 --- a/beacon/goclient/signing.go +++ b/beacon/goclient/signing.go @@ -54,10 +54,10 @@ func (gc *GoClient) DomainData( domain phase0.DomainType, ) (phase0.Domain, error) { switch domain { - case spectypes.DomainApplicationBuilder, spectypes.DomainRequestAuth: + case spectypes.DomainApplicationBuilder, spectypes.DomainBuilderRequestAuth: // Application-namespace domains derive from the network's genesis fork version with a zero // genesis-validators root (compute_domain with defaults), never from a fork-versioned - // state: DomainApplicationBuilder (pre-Gloas validator registrations) and DomainRequestAuth + // state: DomainApplicationBuilder (pre-Gloas validator registrations) and DomainBuilderRequestAuth // (the Gloas direct-builder request auth — 0x0b000001, not the beacon DomainBeaconBuilder // 0x0b000000). var appDomain phase0.Domain diff --git a/go.mod b/go.mod index bf092c153c..3ea1831e7f 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.8.1 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab github.com/ssvlabs/ssv/ssvsigner v0.0.0-20260415125841-05316f77d5e5 github.com/status-im/keycard-go v0.2.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index bca6c0192e..2463b8ff9a 100644 --- a/go.sum +++ b/go.sum @@ -737,6 +737,8 @@ github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50Cc github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab h1:qwxLRgbxrFP47FlIc2zqczIW/q/B5wIOZvYNS1rpgH4= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index ae1dc8b644..f72b552ea2 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -43,9 +43,9 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, return } - // DomainRequestAuth is genesis-style (computed locally by the beacon adapter, no BN call); the - // epoch argument is ignored for it. - domain, err := r.beacon.DomainData(ctx, r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + // DomainBuilderRequestAuth is genesis-style (computed locally by the beacon adapter, no BN + // call); the epoch argument is ignored for it. + domain, err := r.beacon.DomainData(ctx, r.NetworkConfig.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainBuilderRequestAuth)) if err != nil { // Freeze an empty root set: executed-but-froze-nothing (see the requestAuths field doc). r.requestAuths = map[[32]byte]*frozenRequestAuth{} @@ -81,7 +81,7 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, if _, done := r.broadcastAuthRoots[root]; done { continue // broadcast by a prior incarnation; stash replay and live partials finish its quorum } - msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainRequestAuth), domain) + msg, err := signAsValidator(ctx, r, validatorDuty.ValidatorIndex, auth, proposalSlot, phase0.DomainType(spectypes.DomainBuilderRequestAuth), domain) if err != nil { logger.Warn("request auth skipped: could not sign", fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index 51a4bce1c8..b1ff08f502 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -81,7 +81,7 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID, data []byte) *spectypes.PartialSignatureMessages { t.Helper() auth := &gloas.BuilderRequestAuth{Data: data, Slot: proposalSlot} - domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainBuilderRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) require.NoError(t, err) @@ -186,7 +186,7 @@ func TestProposerPreferencesRunner_requestAuthWithoutBuilders(t *testing.T) { }, 3)) auth := &gloas.BuilderRequestAuth{Data: []byte("https://builder.example.com"), Slot: proposalSlot} - domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + domain, err := bn.DomainData(context.Background(), cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainBuilderRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) require.NoError(t, err) @@ -284,7 +284,7 @@ func TestProposerPreferencesRunner_requestAuthAfterPreferenceSuccess(t *testing. // Auth partials arriving after the §5 success must still be collected and reconstructed. authData := []byte("https://builder-a.example.com") auth := &gloas.BuilderRequestAuth{Data: authData, Slot: proposalSlot} - domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainBuilderRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) require.NoError(t, err) @@ -377,7 +377,7 @@ func TestProposerPreferencesRunner_requestAuthSurvivesConcludedReemission(t *tes peerAuthPartial := func(t *testing.T, opID spectypes.OperatorID) *spectypes.PartialSignatureMessages { t.Helper() auth := &gloas.BuilderRequestAuth{Data: []byte("https://builder-a.example.com"), Slot: proposalSlot} - domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainRequestAuth)) + domain, err := bn.DomainData(ctx, cfg.EstimatedEpochAtSlot(proposalSlot), phase0.DomainType(spectypes.DomainBuilderRequestAuth)) require.NoError(t, err) root, err := spectypes.ComputeETHSigningRoot(auth, domain) require.NoError(t, err) diff --git a/protocol/v2/types/gloas/request_auth.go b/protocol/v2/types/gloas/request_auth.go index 05d4e0f513..d079f90569 100644 --- a/protocol/v2/types/gloas/request_auth.go +++ b/protocol/v2/types/gloas/request_auth.go @@ -23,8 +23,9 @@ const MaxBuilderAuthDataSize = 4096 // as advertised — never canonicalized, signed exactly as serialized), and Slot is the proposal slot // the request is authorized for, not the slot at which it is signed or sent. One signed auth covers // both builder channels (getExecutionPayloadBid and submitBuilderPreferences). Signed under -// DomainRequestAuth — a genesis-style compute_domain (fork-agnostic); the wire type is nonetheless -// fork-versioned for decoding, so hops carrying the body set Eth-Consensus-Version. Variable-size SSZ. +// DomainBuilderRequestAuth — a genesis-style compute_domain (fork-agnostic); the wire type is +// nonetheless fork-versioned for decoding, so hops carrying the body set Eth-Consensus-Version. +// Variable-size SSZ. type BuilderRequestAuth struct { Data []byte `ssz-max:"4096"` Slot phase0.Slot diff --git a/ssvsigner/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index 9d60e27e3d..d4db110da3 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -286,11 +286,11 @@ func (km *LocalKeyManager) signBeaconObject( return nil, nil, fmt.Errorf("obj type is unknown: %T", obj) } return km.signer.SignRegistration(data, domain, pubKey[:]) - case spectypes.DomainPTCAttester, spectypes.DomainProposerPreferences, spectypes.DomainBeaconBuilder, spectypes.DomainRequestAuth: + case spectypes.DomainPTCAttester, spectypes.DomainProposerPreferences, spectypes.DomainBeaconBuilder, spectypes.DomainBuilderRequestAuth: // Gloas (ePBS) domains — a plain BLS signature over the SSZ root, no slashing protection (none is // in the slashing predicate): DomainPTCAttester (PTC payload attestation), DomainProposerPreferences // (proposer preferences), DomainBeaconBuilder (§6 blinded execution-payload envelope), and - // DomainRequestAuth (builder-specs BuilderRequestAuth, the direct-builder request auth). + // DomainBuilderRequestAuth (builder-specs BuilderRequestAuth, the direct-builder request auth). return signSSZRoot(km.signer, obj, domain, pubKey[:]) default: return nil, nil, errors.New("domain unknown") diff --git a/ssvsigner/ekm/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index bfd87561ab..063f01da69 100644 --- a/ssvsigner/ekm/local_key_manager_test.go +++ b/ssvsigner/ekm/local_key_manager_test.go @@ -321,7 +321,7 @@ func TestSignBeaconObject(t *testing.T) { {"DomainBeaconBuilder", spectypes.DomainBeaconBuilder}, {"DomainPTCAttester", spectypes.DomainPTCAttester}, {"DomainProposerPreferences", spectypes.DomainProposerPreferences}, - {"DomainRequestAuth", spectypes.DomainRequestAuth}, + {"DomainBuilderRequestAuth", spectypes.DomainBuilderRequestAuth}, } { t.Run(tc.name, func(t *testing.T) { _, sig, err := km.(*LocalKeyManager).SignBeaconObject( diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index 86a2588d1e..c89d8413be 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -421,7 +421,7 @@ func (km *RemoteKeyManager) prepareSignRequest( // but those operators must sign self-build envelopes locally. // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type. return web3signer.SignRequest{}, phase0.Root{}, errors.New("execution payload envelope signing is not supported by the remote signer: Web3Signer has no envelope type, use local signing for self-build envelopes") - case spectypes.DomainRequestAuth: + case spectypes.DomainBuilderRequestAuth: // The Gloas (ePBS) direct-builder request auth (builder-specs BuilderRequestAuth, issue #2962) has no // Web3Signer request type, so a remote-signing operator can't contribute auth partials. Bounded // (the cluster reconstructs while ≤ f operators are remote-signing), but those operators must diff --git a/ssvsigner/go.mod b/ssvsigner/go.mod index 672ca6a68f..f853c6104f 100644 --- a/ssvsigner/go.mod +++ b/ssvsigner/go.mod @@ -30,7 +30,7 @@ require ( github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 github.com/sourcegraph/conc v0.3.0 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 + github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/valyala/fasthttp v1.58.0 diff --git a/ssvsigner/go.sum b/ssvsigner/go.sum index 71763cf157..a2f4ee4f99 100644 --- a/ssvsigner/go.sum +++ b/ssvsigner/go.sum @@ -191,6 +191,8 @@ github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50Cc github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab h1:qwxLRgbxrFP47FlIc2zqczIW/q/B5wIOZvYNS1rpgH4= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 9af2a1a97094d78fbf320e63154baef31712eadc Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 20:40:52 +0300 Subject: [PATCH 130/150] gloas: adopt the produceBlockV4 POST for the direct-builder overlay (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement issue #2962 phase 2 against merged beacon-APIs#630: send the reconstructed per-builder auths on the §4 produce request and forward the block to the winning builder. - protocol/v2/types/gloas: ProduceBuilderConfig/ProduceBuilderEntry (the produceBlockV4 POST body, JSON) plus BuildProduceConfig, which resolves the cluster BuilderConfig against the per-slot reconstructed auths (auth-less builders omitted — #630 requires an auth per entry) and returns the count with no auth for the E1 signal. - beacon/goclient: GetGloasBeaconBlock POSTs the BuilderConfig body when configured, per beacon node falling back to the legacy GET on a 404/405, and reads the winning builder's Eth-Builder-Url; SubmitGloasBeaconBlock echoes it so the beacon node forwards the block. - proposer runner: assemble the body from config + the request-auth cache; record this operator's produced block root and builder URL, and echo Eth-Builder-Url on publish only when the decided block is this operator's own (owner-match, as the §6 envelope-owner gate). - observability: request_auth.unavailable counter (E1) — configured builders with no auth at produce time, the inverse of the reconstruction counter. Not e2e-verifiable until a beacon node ships #630 (lodestar#9832 is an open draft); the GET fallback keeps current beacon nodes working. --- beacon/goclient/gloas_proposer.go | 153 +++++++++++++----- beacon/goclient/gloas_proposer_test.go | 71 +++++++- operator/validator/controller.go | 2 + protocol/v2/blockchain/beacon/client.go | 12 +- protocol/v2/blockchain/beacon/mock_client.go | 42 ++--- protocol/v2/ssv/runner/observability.go | 28 +++- protocol/v2/ssv/runner/proposer.go | 60 ++++++- protocol/v2/ssv/runner/proposer_test.go | 26 ++- .../v2/types/gloas/produce_builder_config.go | 134 +++++++++++++++ .../gloas/produce_builder_config_test.go | 65 ++++++++ 10 files changed, 511 insertions(+), 82 deletions(-) create mode 100644 protocol/v2/types/gloas/produce_builder_config.go create mode 100644 protocol/v2/types/gloas/produce_builder_config_test.go diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 0efb6921c3..dfa1f7e701 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -15,64 +16,118 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) -// Gloas produce/publish endpoints (beacon-APIs#580, merged 2026-06-29). Produce is v4 with -// include_payload=false: a Gloas block carries only the execution-payload bid (the payload ships in the -// §6 envelope), so the response is a bare BeaconBlock — no BlockContents. Publish is the standard v2 -// blocks endpoint (version-tagged via Eth-Consensus-Version). +// Gloas produce/publish endpoints. Produce is v4 with include_payload=false: a Gloas block carries only +// the execution-payload bid (the payload ships in the §6 envelope), so the response is a bare BeaconBlock — +// no BlockContents. The direct-builder overlay (beacon-APIs#630) sends a BuilderConfig POST body; beacon +// nodes that predate it (beacon-APIs#580, GET-only) are handled by the per-node GET fallback. Publish is +// the standard v2 blocks endpoint (version-tagged via Eth-Consensus-Version). const ( gloasProduceBlockPath = "/eth/v4/validator/blocks/%d?randao_reveal=%s&graffiti=%s&include_payload=false" // slot, randao 0x-hex, graffiti 0x-hex gloasPublishBlockPath = "/eth/v2/beacon/blocks" ) -// GetGloasBeaconBlock produces a Gloas (ePBS) block via the v4 produce endpoint as SSZ — go-eth2-client -// has no Gloas types. The response is a bare BeaconBlock (see the include_payload=false path). -func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { - return firstClientResult(ctx, gc, "GetGloasBeaconBlock", http.MethodGet, func(ctx context.Context, addr string) (*gloas.BeaconBlock, error) { - return requestGloasBeaconBlock(ctx, addr, slot, graffiti, randao) +// gloasBlockResult is the produce result threaded through firstClientResult: the block plus the winning +// builder's Eth-Builder-Url (empty when self-built or won by a p2p bid). +type gloasBlockResult struct { + block *gloas.BeaconBlock + builderURL string +} + +// GetGloasBeaconBlock produces a Gloas (ePBS) block via the v4 produce endpoint, decoding the SSZ response +// (go-eth2-client has no Gloas types). A non-nil builderConfig is POSTed as the produceBlockV4 body +// (beacon-APIs#630, direct-builder overlay), falling back per beacon node to the GET for nodes that predate +// it; the returned string is the winning builder's Eth-Builder-Url, if any. +func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { + httpMethod := http.MethodGet + if builderConfig != nil { + httpMethod = http.MethodPost + } + res, err := firstClientResult(ctx, gc, "GetGloasBeaconBlock", httpMethod, func(ctx context.Context, addr string) (gloasBlockResult, error) { + return requestGloasBeaconBlock(ctx, addr, slot, graffiti, randao, builderConfig) }) + return res.block, res.builderURL, err } -// SubmitGloasBeaconBlock publishes a signed Gloas (ePBS) block as SSZ to all configured beacon -// nodes concurrently, succeeding if at least one accepts it. Re-publishing a signed block to -// multiple BNs is safe — they dedupe by block root. -func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { +// SubmitGloasBeaconBlock publishes a signed Gloas (ePBS) block as SSZ to all configured beacon nodes +// concurrently, succeeding if at least one accepts it. Re-publishing a signed block to multiple BNs is +// safe — they dedupe by block root. A non-empty builderURL is echoed as the Eth-Builder-Url header so the +// beacon node forwards the block to the winning builder (beacon-APIs#630); forwarding is idempotent, so +// echoing it to every node is safe. +func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock, builderURL string) error { body, err := block.MarshalSSZ() if err != nil { return fmt.Errorf("marshal signed gloas block: %w", err) } + var extraHeaders map[string]string + if builderURL != "" { + extraHeaders = map[string]string{"Eth-Builder-Url": builderURL} + } + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) defer cancel() return gc.multiClientSubmit(ctx, "SubmitGloasBeaconBlock", func(ctx context.Context, client Client) error { - return submitGloasBeaconBlock(ctx, gc.clientAddresses[client], body) + return submitGloasBeaconBlock(ctx, gc.clientAddresses[client], body, extraHeaders) }) } -// requestGloasBeaconBlock GETs the produce endpoint and decodes the SSZ response into a Gloas block. -func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { - // Graffiti must be a full 32-byte value in the query — lighthouse rejects a short one with - // 400 "Invalid query string" (mirror the mature GetBeaconBlock path which pads to [32]byte). +// requestGloasBeaconBlock produces one Gloas block from a single beacon node. With a builderConfig it POSTs +// the beacon-APIs#630 body and, only on a 404/405 (the node predates the POST), retries as the GET. +func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (gloasBlockResult, error) { + // Graffiti must be a full 32-byte value in the query — lighthouse rejects a short one with 400 + // "Invalid query string" (mirror the mature GetBeaconBlock path which pads to [32]byte). g := [32]byte{} copy(g[:], graffiti) url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(g[:])) - body, err := gloasOctetStreamHTTP(ctx, http.MethodGet, url, nil, nil) + + if builderConfig != nil { + res, err := requestGloasBeaconBlockPOST(ctx, url, builderConfig) + if err == nil { + return res, nil + } + if !isMethodOrPathMissing(err) { + return gloasBlockResult{}, err + } + // The node doesn't implement the produceBlockV4 POST yet; fall through to the GET. + } + + respBody, _, err := gloasHTTPDo(ctx, http.MethodGet, url, nil, "", nil) if err != nil { - return nil, err + return gloasBlockResult{}, err } block := &gloas.BeaconBlock{} - if err := block.UnmarshalSSZ(body); err != nil { - return nil, fmt.Errorf("decode gloas beacon block: %w", err) + if err := block.UnmarshalSSZ(respBody); err != nil { + return gloasBlockResult{}, fmt.Errorf("decode gloas beacon block: %w", err) } - return block, nil + return gloasBlockResult{block: block}, nil } -// submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint. A response -// signaling the block is already known is treated as success: every operator submits the decided block -// for liveness redundancy, so a non-leader's submit legitimately races the canonical one, and some beacon -// nodes (e.g. Lodestar) report that duplicate as an error rather than deduping silently. -func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte) error { - _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ, nil) +// requestGloasBeaconBlockPOST sends the builder config as the produceBlockV4 JSON body and decodes the SSZ +// block response, reading the winning builder's Eth-Builder-Url from the response header. +func requestGloasBeaconBlockPOST(ctx context.Context, url string, builderConfig *gloas.ProduceBuilderConfig) (gloasBlockResult, error) { + jsonBody, err := json.Marshal(builderConfig) + if err != nil { + return gloasBlockResult{}, fmt.Errorf("marshal builder config: %w", err) + } + respBody, header, err := gloasHTTPDo(ctx, http.MethodPost, url, jsonBody, "application/json", nil) + if err != nil { + return gloasBlockResult{}, err + } + block := &gloas.BeaconBlock{} + if err := block.UnmarshalSSZ(respBody); err != nil { + return gloasBlockResult{}, fmt.Errorf("decode gloas beacon block: %w", err) + } + return gloasBlockResult{block: block, builderURL: header.Get("Eth-Builder-Url")}, nil +} + +// submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint, echoing any +// Eth-Builder-Url in extraHeaders. A response signaling the block is already known is treated as success: +// every operator submits the decided block for liveness redundancy, so a non-leader's submit legitimately +// races the canonical one, and some beacon nodes (e.g. Lodestar) report that duplicate as an error rather +// than deduping silently. +func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte, extraHeaders map[string]string) error { + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishBlockPath, blockSSZ, extraHeaders) if isAlreadyKnown(err) { return nil } @@ -93,21 +148,28 @@ func isAlreadyKnown(err error) bool { return strings.Contains(body, "already known") || strings.Contains(body, "already_known") } -// gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns -// the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas -// consensus version. extraHeaders (e.g. Eth-Execution-Payload-Blinded for the §6 envelope) are applied last. -func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, extraHeaders map[string]string) ([]byte, error) { +// isMethodOrPathMissing reports whether err is a 404/405 — the beacon node does not implement the endpoint +// or method, the signal to fall back from the produceBlockV4 POST to the legacy GET. +func isMethodOrPathMissing(err error) bool { + var httpErr *gloasHTTPError + return errors.As(err, &httpErr) && (httpErr.statusCode == http.StatusNotFound || httpErr.statusCode == http.StatusMethodNotAllowed) +} + +// gloasHTTPDo issues an HTTP request to a Gloas endpoint and returns the response body and headers on a 2xx, +// or a *gloasHTTPError otherwise. The Accept is always application/octet-stream (SSZ responses). When body +// is non-nil it is sent with the given contentType and tagged with the Gloas consensus version. +func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, url, reader) if err != nil { - return nil, fmt.Errorf("new request: %w", err) + return nil, nil, fmt.Errorf("new request: %w", err) } req.Header.Set("Accept", "application/octet-stream") if body != nil { - req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Type", contentType) req.Header.Set("Eth-Consensus-Version", consensusVersionGloas) } for k, v := range extraHeaders { @@ -116,22 +178,31 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, resp, err := ptcHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("%s %s: %w", method, url, err) + return nil, nil, fmt.Errorf("%s %s: %w", method, url, err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("read response body: %w", err) + return nil, nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, &gloasHTTPError{method: method, url: url, statusCode: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + return nil, nil, &gloasHTTPError{method: method, url: url, statusCode: resp.StatusCode, body: strings.TrimSpace(string(respBody))} } - return respBody, nil + return respBody, resp.Header, nil +} + +// gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns +// the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas consensus +// version. extraHeaders (e.g. Eth-Builder-Url on publish, Eth-Execution-Payload-Blinded for the §6 +// envelope) are applied last. +func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, extraHeaders map[string]string) ([]byte, error) { + respBody, _, err := gloasHTTPDo(ctx, method, url, body, "application/octet-stream", extraHeaders) + return respBody, err } -// gloasHTTPError is the error gloasOctetStreamHTTP returns for a non-2xx response. It exposes the status -// code and body so callers can special-case specific beacon-node responses (see isAlreadyKnown). +// gloasHTTPError is the error gloasHTTPDo returns for a non-2xx response. It exposes the status code and +// body so callers can special-case specific beacon-node responses (see isAlreadyKnown, isMethodOrPathMissing). type gloasHTTPError struct { method, url string statusCode int diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 4f41c96641..6b03ecac74 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -48,7 +48,7 @@ func TestRequestGloasBeaconBlock(t *testing.T) { })) defer srv.Close() - got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}) + got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, nil) require.NoError(t, err) require.Equal(t, http.MethodGet, gotMethod) require.Equal(t, "/eth/v4/validator/blocks/7", gotPath) @@ -57,7 +57,68 @@ func TestRequestGloasBeaconBlock(t *testing.T) { // graffiti is padded to a full 32-byte value before hex-encoding (lighthouse rejects a short one). require.Equal(t, "0x02"+strings.Repeat("00", 31), gotGraffiti) require.Equal(t, "application/octet-stream", gotAccept) - require.Equal(t, phase0.Slot(7), got.Slot) + require.Equal(t, phase0.Slot(7), got.block.Slot) +} + +// With a builder config, produce is a POST carrying the JSON BuilderConfig body and the winning builder's +// Eth-Builder-Url is read back from the response (beacon-APIs#630). +func TestRequestGloasBeaconBlock_POST(t *testing.T) { + blockSSZ, err := minimalGloasBlock().MarshalSSZ() + require.NoError(t, err) + + var gotMethod, gotContentType, gotConsensusVersion string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentType = r.Header.Get("Content-Type") + gotConsensusVersion = r.Header.Get("Eth-Consensus-Version") + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Eth-Builder-Url", "https://builder.example.com") + _, _ = w.Write(blockSSZ) + })) + defer srv.Close() + + cfg := &gloas.ProduceBuilderConfig{ + MinBid: 10, + BuilderBoostFactor: 100, + Builders: []gloas.ProduceBuilderEntry{{ + URL: "https://builder.example.com", + Auth: &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 7}}, + }}, + } + got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, cfg) + require.NoError(t, err) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "application/json", gotContentType) + require.Equal(t, "gloas", gotConsensusVersion) + require.Contains(t, string(gotBody), `"min_bid":"10"`) + require.Equal(t, "https://builder.example.com", got.builderURL) + require.Equal(t, phase0.Slot(7), got.block.Slot) +} + +// A beacon node that predates the produceBlockV4 POST answers it with 404; produce then retries that node +// as the legacy GET, transparently. +func TestRequestGloasBeaconBlock_POSTFallbackToGET(t *testing.T) { + blockSSZ, err := minimalGloasBlock().MarshalSSZ() + require.NoError(t, err) + + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNotFound) // node predates beacon-APIs#630 + return + } + _, _ = w.Write(blockSSZ) + })) + defer srv.Close() + + cfg := &gloas.ProduceBuilderConfig{BuilderBoostFactor: 100} + got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, cfg) + require.NoError(t, err) + require.Equal(t, []string{http.MethodPost, http.MethodGet}, methods, "POST 404 falls back to GET") + require.Equal(t, phase0.Slot(7), got.block.Slot) + require.Empty(t, got.builderURL) } func TestSubmitGloasBeaconBlock(t *testing.T) { @@ -72,7 +133,7 @@ func TestSubmitGloasBeaconBlock(t *testing.T) { })) defer srv.Close() - err := submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}) + err := submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}, nil) require.NoError(t, err) require.Equal(t, http.MethodPost, gotMethod) require.Equal(t, "/eth/v2/beacon/blocks", gotPath) @@ -101,7 +162,7 @@ func TestSubmitGloasBeaconBlock_AlreadyKnownIsSuccess(t *testing.T) { })) defer srv.Close() - require.NoError(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02})) + require.NoError(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}, nil)) } // A genuine rejection (not "already known") still propagates as an error. @@ -112,7 +173,7 @@ func TestSubmitGloasBeaconBlock_RealErrorPropagates(t *testing.T) { })) defer srv.Close() - require.Error(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02})) + require.Error(t, submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}, nil)) } func TestIsAlreadyKnown(t *testing.T) { diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 0cd922a482..55766fb120 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1280,6 +1280,8 @@ func SetupRunners( ProposerDelayEPBS: options.ProposerDelayEPBS, ProposedBlockRoots: proposedBlockRoots, StartEnvelopeDuty: startEnvelopeDuty, + Builders: options.Builders, + RequestAuthCache: requestAuthCache, }) case spectypes.RoleEnvelopeProposer: // The §6 envelope runner shares the proposer's proposedBlockRoots (it reads the §4 root the diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index a794532248..a4d67d5e38 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -104,10 +104,14 @@ type ProposerPreferencesCalls interface { // merged produce-block-v4 / publish endpoints (beacon-APIs#580). type GloasProposerCalls interface { // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships - // separately in the §6 envelope, so the block carries only the execution-payload bid. - GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) - // SubmitGloasBeaconBlock publishes a signed Gloas block. - SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error + // separately in the §6 envelope, so the block carries only the execution-payload bid. A non-nil + // builderConfig is sent as the produceBlockV4 POST body (beacon-APIs#630, direct-builder overlay), + // with a GET fallback for beacon nodes that predate it; the returned string is the Eth-Builder-Url + // of the winning builder-API bid, empty when self-built or won by a p2p bid. + GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) + // SubmitGloasBeaconBlock publishes a signed Gloas block. A non-empty builderURL is echoed as the + // Eth-Builder-Url header so the beacon node forwards the block to the winning builder (beacon-APIs#630). + SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock, builderURL string) error } // GloasEnvelopeCalls is the beacon-node surface for the §6 execution-payload envelope (SIP #94 §6): diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index fe452adda4..c4b94bf87b 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -557,32 +557,33 @@ func (m *MockGloasProposerCalls) EXPECT() *MockGloasProposerCallsMockRecorder { } // GetGloasBeaconBlock mocks base method. -func (m *MockGloasProposerCalls) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { +func (m *MockGloasProposerCalls) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao, builderConfig) ret0, _ := ret[0].(*gloas.BeaconBlock) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. -func (mr *MockGloasProposerCallsMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { +func (mr *MockGloasProposerCallsMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao, builderConfig any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao, builderConfig) } // SubmitGloasBeaconBlock mocks base method. -func (m *MockGloasProposerCalls) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { +func (m *MockGloasProposerCalls) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock, builderURL string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) + ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block, builderURL) ret0, _ := ret[0].(error) return ret0 } // SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. -func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { +func (mr *MockGloasProposerCallsMockRecorder) SubmitGloasBeaconBlock(ctx, block, builderURL any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockGloasProposerCalls)(nil).SubmitGloasBeaconBlock), ctx, block, builderURL) } // MockGloasEnvelopeCalls is a mock of GloasEnvelopeCalls interface. @@ -1073,18 +1074,19 @@ func (mr *MockBeaconNodeMockRecorder) GetExecutionPayloadEnvelope(ctx, slot, bea } // GetGloasBeaconBlock mocks base method. -func (m *MockBeaconNode) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { +func (m *MockBeaconNode) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao) + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao, builderConfig) ret0, _ := ret[0].(*gloas.BeaconBlock) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // GetGloasBeaconBlock indicates an expected call of GetGloasBeaconBlock. -func (mr *MockBeaconNodeMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao any) *gomock.Call { +func (mr *MockBeaconNodeMockRecorder) GetGloasBeaconBlock(ctx, slot, graffiti, randao, builderConfig any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).GetGloasBeaconBlock), ctx, slot, graffiti, randao, builderConfig) } // GetSyncCommitteeContribution mocks base method. @@ -1291,17 +1293,17 @@ func (mr *MockBeaconNodeMockRecorder) SubmitExecutionPayloadEnvelope(ctx, signed } // SubmitGloasBeaconBlock mocks base method. -func (m *MockBeaconNode) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock) error { +func (m *MockBeaconNode) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.SignedBeaconBlock, builderURL string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block) + ret := m.ctrl.Call(m, "SubmitGloasBeaconBlock", ctx, block, builderURL) ret0, _ := ret[0].(error) return ret0 } // SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. -func (mr *MockBeaconNodeMockRecorder) SubmitGloasBeaconBlock(ctx, block any) *gomock.Call { +func (mr *MockBeaconNodeMockRecorder) SubmitGloasBeaconBlock(ctx, block, builderURL any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).SubmitGloasBeaconBlock), ctx, block) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).SubmitGloasBeaconBlock), ctx, block, builderURL) } // SubmitPayloadAttestationMessages mocks base method. diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 56dc1e498b..13ca06e94b 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -142,6 +142,12 @@ var ( observability.InstrumentName(observabilityNamespace, "request_auth.reconstructions"), metric.WithUnit("{root}"), metric.WithDescription("threshold-reconstructed Gloas direct-builder request-auth signing roots (issue #2962); token-sharing builders share a root and count once"))) + + requestAuthUnavailableCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "request_auth.unavailable"), + metric.WithUnit("{builder}"), + metric.WithDescription("configured Gloas direct-builders with no reconstructed request-auth at §4 produce time (issue #2962 E1); omitted from the produceBlockV4 body, degrading to the enshrined flow"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -160,10 +166,11 @@ func recordDutyOutcome(ctx context.Context, role spectypes.RunnerRole, outcome d )) } -// proposalBuildSource is a submitted Gloas proposal's build source (issue #2962 E1). Today only the -// outcome is knowable — the GET produce doesn't expose why the BN self-built; the produce-POST -// migration will split buildSourceLocal by reason (no bid available / economics / builder auth -// unavailable, the latter fed by the request-auth cache). +// proposalBuildSource is a submitted Gloas proposal's build source (issue #2962 E1): whether the decided +// bid commits to an external builder or to self-build. The decided block cannot reveal why the BN +// self-built (economics vs. a builder being unreachable), so the auth-unavailable dimension is surfaced +// separately, at produce time, by recordProposalAuthUnavailable — a configured builder with no auth this +// slot is a concrete, countable cause independent of this outcome classification. type proposalBuildSource string const ( @@ -196,13 +203,20 @@ func recordEnvelopeBuildMatch(ctx context.Context, self bool) { } // recordRequestAuthReconstruction counts a threshold-reconstructed request-auth signing root -// (issue #2962; token-sharing builders share a root and count once). The inverse signal — an auth -// that never reached quorum — is measured where it bites: at the §4 produce path's cache lookup, -// once the produce-POST migration lands. +// (issue #2962; token-sharing builders share a root and count once). Its inverse — an auth that never +// reached quorum — is measured where it bites, by recordProposalAuthUnavailable at the §4 produce path. func recordRequestAuthReconstruction(ctx context.Context) { requestAuthReconstructionCounter.Add(ctx, 1) } +// recordProposalAuthUnavailable counts configured direct-builders that had no reconstructed request-auth +// for the slot at §4 produce time (issue #2962 E1) — the inverse of recordRequestAuthReconstruction and +// the auth dimension of the build-source telemetry: these builders are omitted from the produceBlockV4 +// body, so the proposal silently degrades to gossiped bids / self-build for them. +func recordProposalAuthUnavailable(ctx context.Context, count int) { + requestAuthUnavailableCounter.Add(ctx, int64(count)) +} + func recordPreConsensusDuration(ctx context.Context, duration time.Duration, role spectypes.RunnerRole) { preConsensusDurationHistogram.Record(ctx, duration.Seconds(), metric.WithAttributes( diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 40754505b1..1405111704 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -71,6 +71,18 @@ type ProposerRunner struct { // It must dispatch async with a node-scoped context: the caller runs on the proposer's post-consensus // path, whose context is canceled once the block duty ends. startEnvelopeDuty func(slot phase0.Slot) + + // builders is the cluster's direct-builder config (issue #2962, phase 2): the produceBlockV4 POST + // body is assembled from it plus the per-slot reconstructed auths. Empty Entries -> the enshrined GET. + builders gloas.BuilderConfig + // requestAuthCache holds the per-slot reconstructed builder auths this operator attaches to the + // produceBlockV4 POST. Shared with the §5 dispatcher that writes it; nil pre-Gloas / no overlay. + requestAuthCache *ssv.RequestAuthCache + // gloasProducedRoot / gloasBuilderURL record this operator's own §4 produce output for the slot: the + // produced block root and any winning builder URL. At publish, Eth-Builder-Url is echoed only when the + // decided block matches gloasProducedRoot (owner-match — see decidedBuilderURL). + gloasProducedRoot [32]byte + gloasBuilderURL string } // ProposerRunnerOptions bundles all dependencies required by NewProposerRunner. @@ -95,6 +107,11 @@ type ProposerRunnerOptions struct { // StartEnvelopeDuty starts the §6 envelope-signing duty for a slot; called after a self-build §4 // block is published. Must dispatch async with a node-scoped context (see startEnvelopeDuty). Optional. StartEnvelopeDuty func(slot phase0.Slot) + + // Builders / RequestAuthCache feed the phase-2 produceBlockV4 POST body (issue #2962). Optional + // (empty / nil pre-Gloas or when the direct-builder overlay is unconfigured). + Builders gloas.BuilderConfig + RequestAuthCache *ssv.RequestAuthCache } func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { @@ -124,6 +141,8 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { proposerDelayEPBS: opts.ProposerDelayEPBS, proposedBlockRoots: opts.ProposedBlockRoots, startEnvelopeDuty: opts.StartEnvelopeDuty, + builders: opts.Builders, + requestAuthCache: opts.RequestAuthCache, }, nil } @@ -273,11 +292,18 @@ func (r *ProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Lo // consensus value (DataSSZ) directly. func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Logger, duty *spectypes.ValidatorDuty, randaoReveal []byte) (*spectypes.ProposerConsensusData, error) { start := time.Now() - block, err := r.GetBeaconNode().GetGloasBeaconBlock(ctx, duty.Slot, r.graffiti, randaoReveal) + builderConfig := r.gloasBuilderConfig(ctx, duty.Slot) + block, builderURL, err := r.GetBeaconNode().GetGloasBeaconBlock(ctx, duty.Slot, r.graffiti, randaoReveal, builderConfig) if err != nil { return nil, fmt.Errorf("get gloas beacon block: %w", err) } + // Remember this operator's own produce output so the §4 publish echoes Eth-Builder-Url only when the + // decided block is this operator's own (owner-match — see decidedBuilderURL). + if root, rootErr := block.HashTreeRoot(); rootErr == nil { + r.gloasProducedRoot, r.gloasBuilderURL = root, builderURL + } + byts, err := block.MarshalSSZ() if err != nil { return nil, fmt.Errorf("could not marshal gloas beacon block: %w", err) @@ -302,6 +328,21 @@ func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Log }, nil } +// gloasBuilderConfig assembles the produceBlockV4 POST body for the direct-builder overlay from the +// cluster config and the per-slot reconstructed auths, or returns nil when no builders are configured (the +// enshrined GET path). Builders whose auth missed quorum this slot are omitted and counted for the E1 +// auth-unavailable signal; the top-level p2p knobs are always carried, so a configured cluster still POSTs. +func (r *ProposerRunner) gloasBuilderConfig(ctx context.Context, slot phase0.Slot) *gloas.ProduceBuilderConfig { + if r.requestAuthCache == nil || len(r.builders.Entries) == 0 { + return nil + } + cfg, authUnavailable := gloas.BuildProduceConfig(r.builders, r.requestAuthCache.Get(slot)) + if authUnavailable > 0 { + recordProposalAuthUnavailable(ctx, authUnavailable) + } + return &cfg +} + func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { // Reuse the existing span instead of generating new one to keep tracing-data lightweight. span := trace.SpanFromContext(ctx) @@ -540,7 +581,7 @@ func (r *ProposerRunner) submitGloasProposal(ctx context.Context, logger *zap.Lo var finishErr error start := time.Now() signedBlock := &gloas.SignedBeaconBlock{Message: block, Signature: sig} - if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock); err != nil { + if err := r.GetBeaconNode().SubmitGloasBeaconBlock(ctx, signedBlock, r.decidedBuilderURL(block)); err != nil { recordFailedSubmission(ctx, spectypes.BNRoleProposer) const errMsg = "could not submit gloas beacon block" logger.Error(errMsg, fields.Slot(cd.Duty.Slot), zap.Error(err)) @@ -564,6 +605,21 @@ func (r *ProposerRunner) triggerEnvelopeIfSelfBuild(block *gloas.BeaconBlock, sl r.startEnvelopeDuty(slot) } +// decidedBuilderURL returns the Eth-Builder-Url to echo on publish: this operator's own produce +// Eth-Builder-Url, but only when the decided block is the one this operator produced (owner-match). A +// follower publishing another operator's decided block echoes nothing — its beacon node did not solicit +// that bid and holds no forwarding target for it. +func (r *ProposerRunner) decidedBuilderURL(block *gloas.BeaconBlock) string { + if r.gloasBuilderURL == "" { + return "" + } + root, err := block.HashTreeRoot() + if err != nil || root != r.gloasProducedRoot { + return "" + } + return r.gloasBuilderURL +} + // selfBuild reports whether the decided Gloas block commits to a self-built payload // (BUILDER_INDEX_SELF_BUILD) rather than an external builder's bid. func selfBuild(block *gloas.BeaconBlock) bool { diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 2afa939622..3b14f5e2f8 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -67,19 +67,39 @@ func (b *proposerTestBeacon) SubmitBeaconBlock(_ context.Context, block *api.Ver return b.submitErr } -func (b *proposerTestBeacon) GetGloasBeaconBlock(_ context.Context, slot phase0.Slot, graffiti, randao []byte) (*gloas.BeaconBlock, error) { +func (b *proposerTestBeacon) GetGloasBeaconBlock(_ context.Context, slot phase0.Slot, graffiti, randao []byte, _ *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { b.getCalls++ b.lastGetSlot = slot b.lastGetGraffiti = append([]byte(nil), graffiti...) b.lastGetRandao = append([]byte(nil), randao...) - return b.getGloasBlock, nil + return b.getGloasBlock, "", nil } -func (b *proposerTestBeacon) SubmitGloasBeaconBlock(_ context.Context, block *gloas.SignedBeaconBlock) error { +func (b *proposerTestBeacon) SubmitGloasBeaconBlock(_ context.Context, block *gloas.SignedBeaconBlock, _ string) error { b.submittedGloasBlocks = append(b.submittedGloasBlocks, block) return b.submitErr } +// decidedBuilderURL echoes this operator's produce Eth-Builder-Url on publish only when the decided block +// is the one this operator produced (owner-match) and a builder bid actually won. +func TestProposerRunner_decidedBuilderURL(t *testing.T) { + block := gloas.TestingBeaconBlock(7) + root, err := block.HashTreeRoot() + require.NoError(t, err) + + // This operator produced the decided block and its BN returned a builder URL -> echo it. + owner := &ProposerRunner{gloasBuilderURL: "https://b.example", gloasProducedRoot: root} + require.Equal(t, "https://b.example", owner.decidedBuilderURL(block)) + + // Another operator's block won QBFT (root mismatch) -> no echo; this BN never solicited that bid. + mismatch := &ProposerRunner{gloasBuilderURL: "https://b.example", gloasProducedRoot: [32]byte{0xff}} + require.Empty(t, mismatch.decidedBuilderURL(block)) + + // Self-build / p2p win (no builder URL) -> no echo even on an owner match. + noURL := &ProposerRunner{gloasBuilderURL: "", gloasProducedRoot: root} + require.Empty(t, noURL.decidedBuilderURL(block)) +} + type stubDoppelganger struct { canSign bool reportQuorum []phase0.ValidatorIndex diff --git a/protocol/v2/types/gloas/produce_builder_config.go b/protocol/v2/types/gloas/produce_builder_config.go new file mode 100644 index 0000000000..da0842c05a --- /dev/null +++ b/protocol/v2/types/gloas/produce_builder_config.go @@ -0,0 +1,134 @@ +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// ProduceBuilderConfig is the beacon-APIs#630 produceBlockV4 POST body: the resolved, auth-attached +// per-builder inputs a proposer sends to its beacon node. It is distinct from the keymanager-APIs#88 +// BuilderConfig (operator config) — here each entry carries the reconstructed SignedBuilderRequestAuth, +// and the top-level MinBid/BuilderBoostFactor govern p2p bids. JSON-encoded on the wire, with uint64 as +// decimal strings per the beacon-API convention. +type ProduceBuilderConfig struct { + MinBid uint64 + BuilderBoostFactor uint64 + Builders []ProduceBuilderEntry +} + +// ProduceBuilderEntry is one builder-API bid request in a ProduceBuilderConfig: the builder URL, the +// reconstructed auth, and the per-builder selection knobs (already resolved against the config defaults). +type ProduceBuilderEntry struct { + URL string + Auth *SignedBuilderRequestAuth + BuilderPubKeys []phase0.BLSPubKey + MaxExecutionPayment uint64 + MinBid uint64 + BuilderBoostFactor uint64 +} + +type produceBuilderConfigJSON struct { + MinBid string `json:"min_bid"` + BuilderBoostFactor string `json:"builder_boost_factor"` + Builders []produceBuilderEntryJSON `json:"builders"` +} + +type produceBuilderEntryJSON struct { + URL string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + BuilderPubKeys []string `json:"builder_pubkeys"` + MaxExecutionPayment string `json:"max_execution_payment"` + MinBid string `json:"min_bid"` + BuilderBoostFactor string `json:"builder_boost_factor"` +} + +// MarshalJSON implements json.Marshaler, emitting the beacon-APIs#630 shape (uint64 as decimal strings, +// pubkeys as 0x-hex, auth as the SignedBuilderRequestAuth object). +func (c *ProduceBuilderConfig) MarshalJSON() ([]byte, error) { + entries := make([]produceBuilderEntryJSON, 0, len(c.Builders)) + for i := range c.Builders { + e := &c.Builders[i] + pubkeys := make([]string, 0, len(e.BuilderPubKeys)) + for _, pk := range e.BuilderPubKeys { + pubkeys = append(pubkeys, fmt.Sprintf("%#x", pk)) + } + entries = append(entries, produceBuilderEntryJSON{ + URL: e.URL, + Auth: e.Auth, + BuilderPubKeys: pubkeys, + MaxExecutionPayment: strconv.FormatUint(e.MaxExecutionPayment, 10), + MinBid: strconv.FormatUint(e.MinBid, 10), + BuilderBoostFactor: strconv.FormatUint(e.BuilderBoostFactor, 10), + }) + } + return json.Marshal(&produceBuilderConfigJSON{ + MinBid: strconv.FormatUint(c.MinBid, 10), + BuilderBoostFactor: strconv.FormatUint(c.BuilderBoostFactor, 10), + Builders: entries, + }) +} + +// builderPubKeys parses the entry's 0x-hex BuilderPubKeys into BLS public keys. The list is validated at +// startup (ValidateBuilderConfig), so an error here is defensive. +func (e *BuilderEntry) builderPubKeys() ([]phase0.BLSPubKey, error) { + if len(e.BuilderPubKeys) == 0 { + return nil, nil + } + out := make([]phase0.BLSPubKey, 0, len(e.BuilderPubKeys)) + for _, s := range e.BuilderPubKeys { + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil { + return nil, fmt.Errorf("invalid builder pubkey hex: %w", err) + } + if len(b) != len(phase0.BLSPubKey{}) { + return nil, fmt.Errorf("builder pubkey must be %d bytes, got %d", len(phase0.BLSPubKey{}), len(b)) + } + var pk phase0.BLSPubKey + copy(pk[:], b) + out = append(out, pk) + } + return out, nil +} + +// BuildProduceConfig resolves cfg against the per-slot reconstructed auths into the produceBlockV4 POST +// body: one entry per configured builder that has a reconstructed auth (auth-less builders are omitted — +// beacon-APIs#630 requires an auth per entry), with per-entry knobs resolved against the config defaults +// (keymanager-APIs#88) and the top-level p2p knobs carried through. It also returns the number of +// configured builders with no reconstructed auth for the slot — the E1 auth-unavailable signal. +func BuildProduceConfig(cfg BuilderConfig, auths map[string]*SignedBuilderRequestAuth) (ProduceBuilderConfig, int) { + out := ProduceBuilderConfig{ + MinBid: cfg.MinBid, + BuilderBoostFactor: cfg.EffectiveBoostFactor(), + } + authUnavailable := 0 + for i := range cfg.Entries { + e := &cfg.Entries[i] + data, err := e.AuthDataBytes() + if err != nil { + continue // validated at startup; skip defensively + } + auth, ok := auths[BuilderIdentity(e.URL, data)] + if !ok { + authUnavailable++ + continue + } + pubkeys, err := e.builderPubKeys() + if err != nil { + continue // validated at startup; skip defensively + } + out.Builders = append(out.Builders, ProduceBuilderEntry{ + URL: e.URL, + Auth: auth, + BuilderPubKeys: pubkeys, + MaxExecutionPayment: e.MaxExecutionPayment, + MinBid: e.EffectiveMinBid(&cfg), + BuilderBoostFactor: e.EffectiveBoostFactor(&cfg), + }) + } + return out, authUnavailable +} diff --git a/protocol/v2/types/gloas/produce_builder_config_test.go b/protocol/v2/types/gloas/produce_builder_config_test.go new file mode 100644 index 0000000000..6018ebc4c5 --- /dev/null +++ b/protocol/v2/types/gloas/produce_builder_config_test.go @@ -0,0 +1,65 @@ +package gloas + +import ( + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestBuildProduceConfig(t *testing.T) { + authA := &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Data: []byte("https://a.example"), Slot: 7}} + ten := uint64(10) + cfg := BuilderConfig{ + MinBid: 5, + BuilderBoostFactor: nil, // -> neutral 100 + Entries: []BuilderEntry{ + {URL: "https://a.example"}, // auth data defaults to the URL bytes; has an auth + {URL: "https://b.example", MinBid: &ten}, // no auth this slot -> omitted + }, + } + auths := map[string]*SignedBuilderRequestAuth{ + BuilderIdentity("https://a.example", []byte("https://a.example")): authA, + } + + body, unavailable := BuildProduceConfig(cfg, auths) + require.Equal(t, 1, unavailable, "builder B has no reconstructed auth for the slot") + require.Equal(t, uint64(5), body.MinBid) + require.Equal(t, uint64(100), body.BuilderBoostFactor, "nil config boost -> neutral 100") + require.Len(t, body.Builders, 1, "only the authed builder is included (auth is required per entry)") + require.Equal(t, "https://a.example", body.Builders[0].URL) + require.Same(t, authA, body.Builders[0].Auth) + require.Equal(t, uint64(5), body.Builders[0].MinBid, "entry omits MinBid -> inherits config default 5") + require.Equal(t, uint64(100), body.Builders[0].BuilderBoostFactor) + + // No reconstructed auths at all -> empty builders list, every configured builder counted unavailable. + empty, un := BuildProduceConfig(cfg, nil) + require.Empty(t, empty.Builders) + require.Equal(t, 2, un) +} + +func TestProduceBuilderConfig_MarshalJSON(t *testing.T) { + body := ProduceBuilderConfig{ + MinBid: 10, + BuilderBoostFactor: 100, + Builders: []ProduceBuilderEntry{{ + URL: "https://a.example", + Auth: &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Data: []byte{0x01}, Slot: 7}}, + BuilderPubKeys: []phase0.BLSPubKey{{0xab}}, + MaxExecutionPayment: 250, + MinBid: 10, + BuilderBoostFactor: 100, + }}, + } + b, err := json.Marshal(&body) + require.NoError(t, err) + s := string(b) + // beacon-API field names, uint64 as decimal strings. + require.Contains(t, s, `"min_bid":"10"`) + require.Contains(t, s, `"builder_boost_factor":"100"`) + require.Contains(t, s, `"max_execution_payment":"250"`) + require.Contains(t, s, `"url":"https://a.example"`) + require.Contains(t, s, `"builder_pubkeys":["0xab00`) // pubkey as 0x-hex + require.Contains(t, s, `"auth":{"message":`) // nested SignedBuilderRequestAuth object +} From 31e00e056407bbcd1395dc002f0a6e0d381b9584 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 20:57:54 +0300 Subject: [PATCH 131/150] =?UTF-8?q?gloas:=20phase-2=20self-review=20?= =?UTF-8?q?=E2=80=94=20dedupe=20the=20produce=20SSZ=20decode;=20comment=20?= =?UTF-8?q?polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the produceBlockV4 POST overlay: extract decodeGloasBlock, shared by the POST and GET produce paths, and note beacon-APIs#630 on the GloasProposerCalls doc. No behavior change. --- beacon/goclient/gloas_proposer.go | 21 +++++++++++++++------ protocol/v2/blockchain/beacon/client.go | 3 ++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index dfa1f7e701..2a830dd38c 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -96,9 +96,9 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, if err != nil { return gloasBlockResult{}, err } - block := &gloas.BeaconBlock{} - if err := block.UnmarshalSSZ(respBody); err != nil { - return gloasBlockResult{}, fmt.Errorf("decode gloas beacon block: %w", err) + block, err := decodeGloasBlock(respBody) + if err != nil { + return gloasBlockResult{}, err } return gloasBlockResult{block: block}, nil } @@ -114,13 +114,22 @@ func requestGloasBeaconBlockPOST(ctx context.Context, url string, builderConfig if err != nil { return gloasBlockResult{}, err } - block := &gloas.BeaconBlock{} - if err := block.UnmarshalSSZ(respBody); err != nil { - return gloasBlockResult{}, fmt.Errorf("decode gloas beacon block: %w", err) + block, err := decodeGloasBlock(respBody) + if err != nil { + return gloasBlockResult{}, err } return gloasBlockResult{block: block, builderURL: header.Get("Eth-Builder-Url")}, nil } +// decodeGloasBlock unmarshals an SSZ produce response into a Gloas block. +func decodeGloasBlock(ssz []byte) (*gloas.BeaconBlock, error) { + block := &gloas.BeaconBlock{} + if err := block.UnmarshalSSZ(ssz); err != nil { + return nil, fmt.Errorf("decode gloas beacon block: %w", err) + } + return block, nil +} + // submitGloasBeaconBlock POSTs an SSZ-marshaled signed Gloas block to the publish endpoint, echoing any // Eth-Builder-Url in extraHeaders. A response signaling the block is already known is treated as success: // every operator submits the decided block for liveness redundancy, so a non-leader's submit legitimately diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index a4d67d5e38..275e167646 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -101,7 +101,8 @@ type ProposerPreferencesCalls interface { // GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks // (SIP #94 §4). go-eth2-client has no Gloas types, so these are hand-rolled over HTTP against the -// merged produce-block-v4 / publish endpoints (beacon-APIs#580). +// merged produce-block-v4 / publish endpoints (beacon-APIs#580), plus the direct-builder produceBlockV4 +// POST (beacon-APIs#630). type GloasProposerCalls interface { // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships // separately in the §6 envelope, so the block carries only the execution-payload bid. A non-nil From 4073af8083a177a4a04d701a8f10bc35fa496c98 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 22:24:16 +0300 Subject: [PATCH 132/150] gloas: submit ahead-of-time builder preferences (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement issue #2962 phase 3 against merged beacon-APIs#630: when the §5 dispatcher reconstructs a builder request-auth (epoch-prior), also submit the ahead-of-time per-builder preferences so the builder holds the max-execution-payment cap before the bid request arrives. - protocol/v2/types/gloas: BuilderPreferencesEntry (proposer_pubkey, url, auth, max_execution_payment) plus JSON. - beacon/goclient: SubmitBuilderPreferences — BN-mediated JSON POST to /eth/v1/validator/builder_preferences (the beacon node forwards each entry to its builder), to all beacon nodes, with a 404 flagged as a missing route. - §5 dispatcher: on reconstruction, submit one entry per builder sharing the auth (its configured cap carried on the frozen ref), reusing the reconstructed SignedBuilderRequestAuth. Best-effort — a failure never disturbs the §5/auth flow. - observability: builder_preferences.submits counter by outcome. All operators submit via their own beacon node (the builder dedupes per proposer per slot). Not e2e-verifiable until a beacon node ships #630. --- beacon/goclient/builder_preferences.go | 43 ++++++++++++++ beacon/goclient/builder_preferences_test.go | 57 +++++++++++++++++++ protocol/v2/blockchain/beacon/client.go | 8 ++- protocol/v2/blockchain/beacon/mock_client.go | 28 +++++++++ protocol/v2/ssv/runner/observability.go | 16 ++++++ .../proposer_preferences_request_auth.go | 35 ++++++++++-- .../ssv/runner/proposer_preferences_test.go | 10 +++- protocol/v2/ssv/runner/request_auth_test.go | 16 ++++++ .../types/gloas/builder_preferences_entry.go | 38 +++++++++++++ 9 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 beacon/goclient/builder_preferences.go create mode 100644 beacon/goclient/builder_preferences_test.go create mode 100644 protocol/v2/types/gloas/builder_preferences_entry.go diff --git a/beacon/goclient/builder_preferences.go b/beacon/goclient/builder_preferences.go new file mode 100644 index 0000000000..5d45dd9d14 --- /dev/null +++ b/beacon/goclient/builder_preferences.go @@ -0,0 +1,43 @@ +package goclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// builderPreferencesPath is the beacon-APIs#630 endpoint through which the beacon node forwards a +// proposer's ahead-of-time per-builder preferences (BN -> builder submitBuilderPreferences); go-eth2-client +// has no Gloas types, so SubmitBuilderPreferences is a hand-rolled JSON POST. +const builderPreferencesPath = "/eth/v1/validator/builder_preferences" + +// SubmitBuilderPreferences submits the ahead-of-time per-builder preferences (issue #2962 phase 3) to +// every beacon client, succeeding if at least one accepts them; each beacon node forwards every entry to +// its builder's submitBuilderPreferences. It is best-effort — the caller never blocks on the result. +func (gc *GoClient) SubmitBuilderPreferences(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error { + ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) + defer cancel() + + return gc.multiClientSubmit(ctx, "SubmitBuilderPreferences", func(ctx context.Context, client Client) error { + return submitBuilderPreferences(ctx, ptcHTTPClient, gc.clientAddresses[client], preferences) + }) +} + +// submitBuilderPreferences POSTs the preferences as a JSON array to the validator endpoint. A 404 is +// flagged as a missing route — a beacon node predating the merged beacon-APIs#630 endpoint — rather than +// a transient failure. +func submitBuilderPreferences(ctx context.Context, httpClient *http.Client, addr string, preferences []*gloas.BuilderPreferencesEntry) error { + body, err := json.Marshal(preferences) + if err != nil { + return fmt.Errorf("marshal builder preferences: %w", err) + } + headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + err = ptcDo(ctx, httpClient, http.MethodPost, addr+builderPreferencesPath, body, headers, nil) + if isNotFound(err) { + return fmt.Errorf("beacon node lacks the gloas builder_preferences endpoint (beacon-APIs#630): %w", err) + } + return err +} diff --git a/beacon/goclient/builder_preferences_test.go b/beacon/goclient/builder_preferences_test.go new file mode 100644 index 0000000000..2b82a2df83 --- /dev/null +++ b/beacon/goclient/builder_preferences_test.go @@ -0,0 +1,57 @@ +package goclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +func TestSubmitBuilderPreferences(t *testing.T) { + prefs := []*gloas.BuilderPreferencesEntry{{ + ProposerPubKey: phase0.BLSPubKey{0xab}, + URL: "https://builder.example.com", + Auth: &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 9}}, + MaxExecutionPayment: 250, + }} + + var gotMethod, gotPath, gotVersion string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotVersion = r.Header.Get("Eth-Consensus-Version") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + require.NoError(t, submitBuilderPreferences(context.Background(), srv.Client(), srv.URL, prefs)) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/eth/v1/validator/builder_preferences", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + require.Contains(t, string(gotBody), `"max_execution_payment":"250"`) // uint64 as a decimal string + require.Contains(t, string(gotBody), `"proposer_pubkey":"0xab00`) // pubkey as 0x-hex + want, err := json.Marshal(prefs) + require.NoError(t, err) + require.JSONEq(t, string(want), string(gotBody)) +} + +// A 404 — a beacon node predating the merged beacon-APIs#630 endpoint — is flagged as a missing endpoint +// rather than a transient failure. +func TestSubmitBuilderPreferencesMissingRoute(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":404,"message":"not found"}`, http.StatusNotFound) + })) + defer srv.Close() + + err := submitBuilderPreferences(context.Background(), srv.Client(), srv.URL, nil) + require.ErrorContains(t, err, "beacon node lacks the gloas builder_preferences endpoint") + require.ErrorContains(t, err, "status 404") +} diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 275e167646..bdafa65011 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -89,14 +89,18 @@ type PTCCalls interface { SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error } -// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences (SIP #94 §5). -// go-eth2-client has no Gloas types, so these are hand-rolled over HTTP. +// ProposerPreferencesCalls is the beacon-node surface for Gloas (ePBS) proposer preferences (SIP #94 §5) +// and the direct-builder preferences the §5 dispatcher submits (issue #2962 phase 3). go-eth2-client has +// no Gloas types, so these are hand-rolled over HTTP. type ProposerPreferencesCalls interface { // ProposerDutiesDependentRoot returns the proposer-duties dependent root for the epoch — the // seed the proposer-lookahead is pinned to. go-eth2-client drops it, so it's fetched via raw HTTP. ProposerDutiesDependentRoot(ctx context.Context, epoch phase0.Epoch) (phase0.Root, error) // SubmitProposerPreferences broadcasts signed proposer preferences for upcoming proposal slots. SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error + // SubmitBuilderPreferences submits ahead-of-time per-builder preferences; the beacon node forwards + // each entry to its builder (beacon-APIs#630, issue #2962 phase 3). + SubmitBuilderPreferences(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error } // GloasProposerCalls is the beacon-node surface for producing and publishing Gloas (ePBS) blocks diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index c4b94bf87b..60e0c3b379 100644 --- a/protocol/v2/blockchain/beacon/mock_client.go +++ b/protocol/v2/blockchain/beacon/mock_client.go @@ -518,6 +518,20 @@ func (mr *MockProposerPreferencesCallsMockRecorder) ProposerDutiesDependentRoot( return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProposerDutiesDependentRoot", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).ProposerDutiesDependentRoot), ctx, epoch) } +// SubmitBuilderPreferences mocks base method. +func (m *MockProposerPreferencesCalls) SubmitBuilderPreferences(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitBuilderPreferences", ctx, preferences) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitBuilderPreferences indicates an expected call of SubmitBuilderPreferences. +func (mr *MockProposerPreferencesCallsMockRecorder) SubmitBuilderPreferences(ctx, preferences any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBuilderPreferences", reflect.TypeOf((*MockProposerPreferencesCalls)(nil).SubmitBuilderPreferences), ctx, preferences) +} + // SubmitProposerPreferences mocks base method. func (m *MockProposerPreferencesCalls) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { m.ctrl.T.Helper() @@ -1278,6 +1292,20 @@ func (mr *MockBeaconNodeMockRecorder) SubmitBeaconCommitteeSubscriptions(ctx, su return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBeaconCommitteeSubscriptions", reflect.TypeOf((*MockBeaconNode)(nil).SubmitBeaconCommitteeSubscriptions), ctx, subscription) } +// SubmitBuilderPreferences mocks base method. +func (m *MockBeaconNode) SubmitBuilderPreferences(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitBuilderPreferences", ctx, preferences) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitBuilderPreferences indicates an expected call of SubmitBuilderPreferences. +func (mr *MockBeaconNodeMockRecorder) SubmitBuilderPreferences(ctx, preferences any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBuilderPreferences", reflect.TypeOf((*MockBeaconNode)(nil).SubmitBuilderPreferences), ctx, preferences) +} + // SubmitExecutionPayloadEnvelope mocks base method. func (m *MockBeaconNode) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { m.ctrl.T.Helper() diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 13ca06e94b..aca763a958 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -148,6 +148,12 @@ var ( observability.InstrumentName(observabilityNamespace, "request_auth.unavailable"), metric.WithUnit("{builder}"), metric.WithDescription("configured Gloas direct-builders with no reconstructed request-auth at §4 produce time (issue #2962 E1); omitted from the produceBlockV4 body, degrading to the enshrined flow"))) + + builderPreferencesSubmitCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "builder_preferences.submits"), + metric.WithUnit("{submit}"), + metric.WithDescription("ahead-of-time Gloas builder-preferences submissions to the beacon node (issue #2962 phase 3), by outcome"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -217,6 +223,16 @@ func recordProposalAuthUnavailable(ctx context.Context, count int) { requestAuthUnavailableCounter.Add(ctx, int64(count)) } +// recordBuilderPreferencesSubmit counts an ahead-of-time builder-preferences submission (issue #2962 +// phase 3) by outcome. It is best-effort at the caller, so a failure is a health signal, not a duty failure. +func recordBuilderPreferencesSubmit(ctx context.Context, success bool) { + outcome := "failure" + if success { + outcome = "success" + } + builderPreferencesSubmitCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("outcome", outcome))) +} + func recordPreConsensusDuration(ctx context.Context, duration time.Duration, role spectypes.RunnerRole) { preConsensusDurationHistogram.Record(ctx, duration.Seconds(), metric.WithAttributes( diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index f72b552ea2..3540132177 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -29,8 +29,9 @@ type frozenRequestAuth struct { // frozenBuilderRef names one configured builder relationship covered by a frozen auth. type frozenBuilderRef struct { - identity string // gloas.BuilderIdentity — the RequestAuthCache key - url string // for logging + identity string // gloas.BuilderIdentity — the RequestAuthCache key + url string // the builder URL, for logging and the phase-3 preferences submit + maxExecutionPayment uint64 // the configured cap, forwarded via submitBuilderPreferences (phase 3) } // runRequestAuthRound freezes one BuilderRequestAuth{data, proposal_slot} per configured builder, @@ -70,7 +71,7 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) continue } - ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL} + ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL, maxExecutionPayment: entry.MaxExecutionPayment} if frozen, ok := r.requestAuths[root]; ok { // Another entry froze these exact bytes; register the extra relationship on the shared root. frozen.builders = append(frozen.builders, ref) @@ -158,18 +159,44 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co copy(signature[:], fullSig) r.reconstructedAuthRoots[msg.SigningRoot] = struct{}{} + signed := &gloas.SignedBuilderRequestAuth{Message: frozen.auth, Signature: signature} urls := make([]string, 0, len(frozen.builders)) for _, ref := range frozen.builders { urls = append(urls, ref.url) } if r.requestAuthCache != nil { - signed := &gloas.SignedBuilderRequestAuth{Message: frozen.auth, Signature: signature} for _, ref := range frozen.builders { r.requestAuthCache.Store(frozen.auth.Slot, ref.identity, signed) } } + r.submitBuilderPreferences(ctx, logger, signed, frozen.builders) recordRequestAuthReconstruction(ctx) logger.Info("✔️ reconstructed builder request auth", fields.Slot(frozen.auth.Slot), zap.Strings("builder_urls", urls)) return nil } + +// submitBuilderPreferences forwards the reconstructed auth as the ahead-of-time per-builder preference +// (issue #2962 phase 3, beacon-APIs#630): one BuilderPreferencesEntry per builder sharing the auth, each +// carrying the proposer pubkey, the builder URL, and the configured max-execution-payment cap. Every +// operator submits via its own beacon node — the builder dedupes per proposer per slot. Best-effort: a +// failure never disturbs the §5/auth flow, only its metric and log. +func (r *proposerPreferencesSlotRunner) submitBuilderPreferences(ctx context.Context, logger *zap.Logger, signed *gloas.SignedBuilderRequestAuth, builders []frozenBuilderRef) { + var pubkey phase0.BLSPubKey + copy(pubkey[:], r.GetShare().ValidatorPubKey[:]) + entries := make([]*gloas.BuilderPreferencesEntry, 0, len(builders)) + for _, ref := range builders { + entries = append(entries, &gloas.BuilderPreferencesEntry{ + ProposerPubKey: pubkey, + URL: ref.url, + Auth: signed, + MaxExecutionPayment: ref.maxExecutionPayment, + }) + } + if err := r.beacon.SubmitBuilderPreferences(ctx, entries); err != nil { + recordBuilderPreferencesSubmit(ctx, false) + logger.Warn("builder preferences submit failed", fields.Slot(signed.Message.Slot), zap.Error(err)) + return + } + recordBuilderPreferencesSubmit(ctx, true) +} diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index e012f95fb9..b50ab5ec3e 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -38,8 +38,9 @@ func (p fixedFeeRecipientProvider) GetFeeRecipient(spectypes.ValidatorPK) (bella // surface: a settable dependent root and a capture of submitted preferences. type prefsTestBeacon struct { beacon.BeaconNode - dependentRoot phase0.Root - submitted [][]*gloas.SignedProposerPreferences + dependentRoot phase0.Root + submitted [][]*gloas.SignedProposerPreferences + submittedBuilderPrefs [][]*gloas.BuilderPreferencesEntry } func (b *prefsTestBeacon) ProposerDutiesDependentRoot(context.Context, phase0.Epoch) (phase0.Root, error) { @@ -51,6 +52,11 @@ func (b *prefsTestBeacon) SubmitProposerPreferences(_ context.Context, prefs []* return nil } +func (b *prefsTestBeacon) SubmitBuilderPreferences(_ context.Context, prefs []*gloas.BuilderPreferencesEntry) error { + b.submittedBuilderPrefs = append(b.submittedBuilderPrefs, prefs) + return nil +} + func TestNewProposerPreferencesRunner_RequiresSingleShare(t *testing.T) { _, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{}) require.Error(t, err) diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index b1ff08f502..9e63993589 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -140,6 +140,22 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { require.NotNil(t, auths[gloas.BuilderIdentity("https://builder-d.example.com", builderBData)], "a builder sharing another's token must get its own cache entry from the shared reconstruction") + // Phase 3: each reconstruction also submits the builder preferences to the beacon node. Across the + // two reconstructions (builder A via stash replay, then the B/D shared root) every configured builder + // is submitted, each carrying its reconstructed auth. + var submittedURLs []string + for _, batch := range bn.submittedBuilderPrefs { + for _, e := range batch { + submittedURLs = append(submittedURLs, e.URL) + require.NotNil(t, e.Auth, "each submitted preference carries the reconstructed auth") + } + } + require.ElementsMatch(t, []string{ + "https://builder-a.example.com", + "https://builder-b.example.com", + "https://builder-d.example.com", + }, submittedURLs) + // A re-emission for the same slot re-freezes but never re-broadcasts an auth (roots are // re-emission-invariant and already out) — only the §5 side decides re-broadcast on its own rules. broadcastsBefore := len(network.BroadcastedMsgs) diff --git a/protocol/v2/types/gloas/builder_preferences_entry.go b/protocol/v2/types/gloas/builder_preferences_entry.go new file mode 100644 index 0000000000..7fa00dd67f --- /dev/null +++ b/protocol/v2/types/gloas/builder_preferences_entry.go @@ -0,0 +1,38 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// BuilderPreferencesEntry is one entry in the beacon-APIs#630 submitBuilderPreferences body (issue #2962 +// phase 3): the ahead-of-time per-builder preference a proposer asks its beacon node to forward. The +// beacon node routes it by URL to that builder's submitBuilderPreferences for ProposerPubKey, so the +// builder holds MaxExecutionPayment (authenticated by Auth) before the bid request arrives. JSON-encoded +// on the wire, with uint64 as a decimal string per the beacon-API convention. +type BuilderPreferencesEntry struct { + ProposerPubKey phase0.BLSPubKey + URL string + Auth *SignedBuilderRequestAuth + MaxExecutionPayment uint64 +} + +type builderPreferencesEntryJSON struct { + ProposerPubKey string `json:"proposer_pubkey"` + URL string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + MaxExecutionPayment string `json:"max_execution_payment"` +} + +// MarshalJSON implements json.Marshaler, emitting the beacon-APIs#630 shape. +func (e *BuilderPreferencesEntry) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderPreferencesEntryJSON{ + ProposerPubKey: fmt.Sprintf("%#x", e.ProposerPubKey), + URL: e.URL, + Auth: e.Auth, + MaxExecutionPayment: strconv.FormatUint(e.MaxExecutionPayment, 10), + }) +} From 99824aed39559c7cd2fca052891bd56de9fd4b40 Mon Sep 17 00:00:00 2001 From: iurii Date: Tue, 25 Aug 2026 22:38:45 +0300 Subject: [PATCH 133/150] =?UTF-8?q?gloas:=20phase-3=20self-review=20?= =?UTF-8?q?=E2=80=94=20simplify=20the=20proposer-pubkey=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidatorPK is defined as phase0.BLSPubKey, so convert directly rather than copying into a zero value. No behavior change. --- protocol/v2/ssv/runner/proposer_preferences_request_auth.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index 3540132177..9679d984d2 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -182,8 +182,7 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co // operator submits via its own beacon node — the builder dedupes per proposer per slot. Best-effort: a // failure never disturbs the §5/auth flow, only its metric and log. func (r *proposerPreferencesSlotRunner) submitBuilderPreferences(ctx context.Context, logger *zap.Logger, signed *gloas.SignedBuilderRequestAuth, builders []frozenBuilderRef) { - var pubkey phase0.BLSPubKey - copy(pubkey[:], r.GetShare().ValidatorPubKey[:]) + pubkey := phase0.BLSPubKey(r.GetShare().ValidatorPubKey) entries := make([]*gloas.BuilderPreferencesEntry, 0, len(builders)) for _, ref := range builders { entries = append(entries, &gloas.BuilderPreferencesEntry{ From 53fb2facae81baf20f6b6d7c4dde4c63e10723e2 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 09:44:46 +0300 Subject: [PATCH 134/150] gloas: deferred goclient refinements (produce version guard, HTTP-helper unification) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work three items off the #2901 deferred-refinements list: - Assert the produce response's Eth-Consensus-Version is Gloas — lenient: a present wrong-fork value fails, an absent header is tolerated with the SSZ decode as backstop. - Unify the hand-rolled JSON and SSZ HTTP helpers on a shared httpDo core and a single httpStatusError type (drops the duplicate gloasHTTPError). - Reuse the shared gloas.TestingBeaconBlock fixture in the goclient proposer tests. The fourth item — relocating DefaultGasLimit/feeRecipientProvider out of validator_registration.go — is gated on the VR runner being removed post-fork, which has not happened, so it stays deferred. --- beacon/goclient/gloas_proposer.go | 80 ++++++++++---------------- beacon/goclient/gloas_proposer_test.go | 43 +++++++------- beacon/goclient/ptc.go | 33 +++++++---- 3 files changed, 74 insertions(+), 82 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 2a830dd38c..1e9e910637 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -1,13 +1,11 @@ package goclient import ( - "bytes" "context" "encoding/hex" "encoding/json" "errors" "fmt" - "io" "net/http" "strings" @@ -92,10 +90,13 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, // The node doesn't implement the produceBlockV4 POST yet; fall through to the GET. } - respBody, _, err := gloasHTTPDo(ctx, http.MethodGet, url, nil, "", nil) + respBody, header, err := gloasHTTPDo(ctx, http.MethodGet, url, nil, "", nil) if err != nil { return gloasBlockResult{}, err } + if err := checkGloasConsensusVersion(header); err != nil { + return gloasBlockResult{}, err + } block, err := decodeGloasBlock(respBody) if err != nil { return gloasBlockResult{}, err @@ -114,6 +115,9 @@ func requestGloasBeaconBlockPOST(ctx context.Context, url string, builderConfig if err != nil { return gloasBlockResult{}, err } + if err := checkGloasConsensusVersion(header); err != nil { + return gloasBlockResult{}, err + } block, err := decodeGloasBlock(respBody) if err != nil { return gloasBlockResult{}, err @@ -121,6 +125,16 @@ func requestGloasBeaconBlockPOST(ctx context.Context, url string, builderConfig return gloasBlockResult{block: block, builderURL: header.Get("Eth-Builder-Url")}, nil } +// checkGloasConsensusVersion guards against a beacon node returning a wrong-fork block: it fails when the +// produce response's Eth-Consensus-Version is present but not "gloas". An absent header is tolerated (not +// every node sets it on the response), with the SSZ decode as the backstop. +func checkGloasConsensusVersion(header http.Header) error { + if v := header.Get("Eth-Consensus-Version"); v != "" && !strings.EqualFold(v, consensusVersionGloas) { + return fmt.Errorf("produce response Eth-Consensus-Version %q, want %q", v, consensusVersionGloas) + } + return nil +} + // decodeGloasBlock unmarshals an SSZ produce response into a Gloas block. func decodeGloasBlock(ssz []byte) (*gloas.BeaconBlock, error) { block := &gloas.BeaconBlock{} @@ -149,7 +163,7 @@ func submitGloasBeaconBlock(ctx context.Context, addr string, blockSSZ []byte, e // standard code for this, so match on the message: Lodestar returns 500 "BLOCK_ERROR_ALREADY_KNOWN" and // "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN". func isAlreadyKnown(err error) bool { - var httpErr *gloasHTTPError + var httpErr *httpStatusError if !errors.As(err, &httpErr) { return false } @@ -160,45 +174,23 @@ func isAlreadyKnown(err error) bool { // isMethodOrPathMissing reports whether err is a 404/405 — the beacon node does not implement the endpoint // or method, the signal to fall back from the produceBlockV4 POST to the legacy GET. func isMethodOrPathMissing(err error) bool { - var httpErr *gloasHTTPError - return errors.As(err, &httpErr) && (httpErr.statusCode == http.StatusNotFound || httpErr.statusCode == http.StatusMethodNotAllowed) + var httpErr *httpStatusError + return errors.As(err, &httpErr) && (httpErr.status == http.StatusNotFound || httpErr.status == http.StatusMethodNotAllowed) } -// gloasHTTPDo issues an HTTP request to a Gloas endpoint and returns the response body and headers on a 2xx, -// or a *gloasHTTPError otherwise. The Accept is always application/octet-stream (SSZ responses). When body -// is non-nil it is sent with the given contentType and tagged with the Gloas consensus version. +// gloasHTTPDo issues an SSZ-accepting request to a Gloas endpoint and returns the response body and headers +// on a 2xx (see httpDo). A non-nil body is sent with the given contentType and tagged with the Gloas +// consensus version; extraHeaders are applied last. func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { - var reader io.Reader - if body != nil { - reader = bytes.NewReader(body) - } - req, err := http.NewRequestWithContext(ctx, method, url, reader) - if err != nil { - return nil, nil, fmt.Errorf("new request: %w", err) - } - req.Header.Set("Accept", "application/octet-stream") if body != nil { - req.Header.Set("Content-Type", contentType) - req.Header.Set("Eth-Consensus-Version", consensusVersionGloas) - } - for k, v := range extraHeaders { - req.Header.Set(k, v) - } - - resp, err := ptcHTTPClient.Do(req) - if err != nil { - return nil, nil, fmt.Errorf("%s %s: %w", method, url, err) - } - defer func() { _ = resp.Body.Close() }() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("read response body: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, nil, &gloasHTTPError{method: method, url: url, statusCode: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + merged := make(map[string]string, len(extraHeaders)+1) + for k, v := range extraHeaders { + merged[k] = v + } + merged["Eth-Consensus-Version"] = consensusVersionGloas + extraHeaders = merged } - return respBody, resp.Header, nil + return httpDo(ctx, ptcHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) } // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns @@ -209,15 +201,3 @@ func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, respBody, _, err := gloasHTTPDo(ctx, method, url, body, "application/octet-stream", extraHeaders) return respBody, err } - -// gloasHTTPError is the error gloasHTTPDo returns for a non-2xx response. It exposes the status code and -// body so callers can special-case specific beacon-node responses (see isAlreadyKnown, isMethodOrPathMissing). -type gloasHTTPError struct { - method, url string - statusCode int - body string -} - -func (e *gloasHTTPError) Error() string { - return fmt.Sprintf("%s %s: status %d: %s", e.method, e.url, e.statusCode, e.body) -} diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 6b03ecac74..680b344de5 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -9,9 +9,7 @@ import ( "strings" "testing" - "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/phase0" - bitfield "github.com/prysmaticlabs/go-bitfield" "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" @@ -21,20 +19,8 @@ import ( // GoClient must satisfy the Gloas proposer beacon-node surface. var _ beacon.GloasProposerCalls = (*GoClient)(nil) -func minimalGloasBlock() *gloas.BeaconBlock { - return &gloas.BeaconBlock{ - Slot: 7, - Body: &gloas.BeaconBlockBody{ - ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, 32)}, - SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, - SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{BuilderIndex: gloas.BuilderIndexSelfBuild}}, - ParentExecutionRequests: &gloas.ExecutionRequests{}, - }, - } -} - func TestRequestGloasBeaconBlock(t *testing.T) { - blockSSZ, err := minimalGloasBlock().MarshalSSZ() + blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotIncludePayload string @@ -63,7 +49,7 @@ func TestRequestGloasBeaconBlock(t *testing.T) { // With a builder config, produce is a POST carrying the JSON BuilderConfig body and the winning builder's // Eth-Builder-Url is read back from the response (beacon-APIs#630). func TestRequestGloasBeaconBlock_POST(t *testing.T) { - blockSSZ, err := minimalGloasBlock().MarshalSSZ() + blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) var gotMethod, gotContentType, gotConsensusVersion string @@ -99,7 +85,7 @@ func TestRequestGloasBeaconBlock_POST(t *testing.T) { // A beacon node that predates the produceBlockV4 POST answers it with 404; produce then retries that node // as the legacy GET, transparently. func TestRequestGloasBeaconBlock_POSTFallbackToGET(t *testing.T) { - blockSSZ, err := minimalGloasBlock().MarshalSSZ() + blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) var methods []string @@ -121,6 +107,21 @@ func TestRequestGloasBeaconBlock_POSTFallbackToGET(t *testing.T) { require.Empty(t, got.builderURL) } +// A produce response tagged with a non-Gloas Eth-Consensus-Version is rejected — a wrong-fork guard. +func TestRequestGloasBeaconBlock_WrongConsensusVersion(t *testing.T) { + blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Eth-Consensus-Version", "fulu") + _, _ = w.Write(blockSSZ) + })) + defer srv.Close() + + _, err = requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, nil) + require.ErrorContains(t, err, "Eth-Consensus-Version") + require.ErrorContains(t, err, "fulu") +} + func TestSubmitGloasBeaconBlock(t *testing.T) { var gotMethod, gotPath, gotVersion, gotContentType string var gotBody []byte @@ -179,8 +180,8 @@ func TestSubmitGloasBeaconBlock_RealErrorPropagates(t *testing.T) { func TestIsAlreadyKnown(t *testing.T) { require.False(t, isAlreadyKnown(nil)) require.False(t, isAlreadyKnown(errors.New("some other error"))) - require.False(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusBadRequest, body: "invalid block"})) - require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"BLOCK_ERROR_ALREADY_KNOWN"}`})) - require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusInternalServerError, body: `{"message":"EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN"}`})) - require.True(t, isAlreadyKnown(&gloasHTTPError{statusCode: http.StatusAccepted, body: "block already known"})) + require.False(t, isAlreadyKnown(&httpStatusError{status: http.StatusBadRequest, body: "invalid block"})) + require.True(t, isAlreadyKnown(&httpStatusError{status: http.StatusInternalServerError, body: `{"message":"BLOCK_ERROR_ALREADY_KNOWN"}`})) + require.True(t, isAlreadyKnown(&httpStatusError{status: http.StatusInternalServerError, body: `{"message":"EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN"}`})) + require.True(t, isAlreadyKnown(&httpStatusError{status: http.StatusAccepted, body: "block already known"})) } diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 26b2d8d8be..f73a4d4d69 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -141,21 +141,21 @@ func (e *httpStatusError) Error() string { return fmt.Sprintf("%s %s: status %d: %s", e.method, e.url, e.status, e.body) } -// ptcDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil -// to ignore the body). A nil body sends no request payload; extraHeaders are applied last. -// Non-2xx responses surface as *httpStatusError. -func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { +// httpDo issues a hand-rolled Gloas HTTP request and returns the response body and headers on a 2xx, or +// a *httpStatusError otherwise. accept sets the Accept header; a non-nil body is sent with contentType. +// extraHeaders are applied last. It is the shared core of the JSON (ptcDo) and SSZ (gloasHTTPDo) helpers. +func httpDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, accept, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, url, reader) if err != nil { - return fmt.Errorf("new request: %w", err) + return nil, nil, fmt.Errorf("new request: %w", err) } - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", accept) + if body != nil && contentType != "" { + req.Header.Set("Content-Type", contentType) } for k, v := range extraHeaders { req.Header.Set(k, v) @@ -163,16 +163,27 @@ func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, bod resp, err := httpClient.Do(req) if err != nil { - return fmt.Errorf("%s %s: %w", method, url, err) + return nil, nil, fmt.Errorf("%s %s: %w", method, url, err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("read response body: %w", err) + return nil, nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + return nil, nil, &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + } + return respBody, resp.Header, nil +} + +// ptcDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil to +// ignore the body). A nil body sends no request payload; extraHeaders are applied last. Non-2xx +// responses surface as *httpStatusError. +func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { + respBody, _, err := httpDo(ctx, httpClient, method, url, body, "application/json", "application/json", extraHeaders) + if err != nil { + return err } if out != nil { if err := json.Unmarshal(respBody, out); err != nil { From 94ca5e1bcad2cce3e127c90a4400915b0add869f Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 11:36:57 +0300 Subject: [PATCH 135/150] =?UTF-8?q?gloas:=20code-review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20POST=20on=20a=20knobs-only=20config;=20comment/doc?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review of the direct-builder overlay (issue #2962): - §4 now POSTs produceBlockV4 whenever the cluster set ANY direct-builder config, not only when Entries are non-empty: a knobs-only config (top-level MinBid / BuilderBoostFactor) is honored, and dropping a remote signer's entries keeps its p2p knobs. Gated on the new BuilderConfig.Configured(); the per-slot auth cache is now read under a nil guard. - Reword the batch-level builder-preferences metric: one submit call per reconstructed auth root, so a beacon-APIs#630 partial 400 books the whole call a failure (the per-entry error rides the caller's warn log). - Fix now-stale "not yet honored / no reader yet / never blocks" comments in config.example.yaml, EXTERNAL_BUILDERS.md, request_auth_cache.go, builder_entry.go and builder_preferences.go — the POST attach and the ahead-of-time submit both landed — and add a "How it works" doc section. --- beacon/goclient/builder_preferences.go | 3 ++- cli/operator/node.go | 2 +- config/config.example.yaml | 4 +-- docs/EXTERNAL_BUILDERS.md | 27 ++++++++++++++++--- protocol/v2/ssv/request_auth_cache.go | 7 +++-- protocol/v2/ssv/runner/observability.go | 9 ++++--- protocol/v2/ssv/runner/proposer.go | 17 +++++++----- .../proposer_preferences_request_auth.go | 4 ++- protocol/v2/types/gloas/builder_entry.go | 12 +++++++-- protocol/v2/types/gloas/builder_entry_test.go | 8 ++++++ 10 files changed, 70 insertions(+), 23 deletions(-) diff --git a/beacon/goclient/builder_preferences.go b/beacon/goclient/builder_preferences.go index 5d45dd9d14..7d20485c2d 100644 --- a/beacon/goclient/builder_preferences.go +++ b/beacon/goclient/builder_preferences.go @@ -16,7 +16,8 @@ const builderPreferencesPath = "/eth/v1/validator/builder_preferences" // SubmitBuilderPreferences submits the ahead-of-time per-builder preferences (issue #2962 phase 3) to // every beacon client, succeeding if at least one accepts them; each beacon node forwards every entry to -// its builder's submitBuilderPreferences. It is best-effort — the caller never blocks on the result. +// its builder's submitBuilderPreferences. The call is synchronous (bounded by commonTimeout) but +// best-effort: callers do not gate on the outcome — a failure only surfaces in metrics and logs. func (gc *GoClient) SubmitBuilderPreferences(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error { ctx, cancel := context.WithTimeout(ctx, gc.commonTimeout) defer cancel() diff --git a/cli/operator/node.go b/cli/operator/node.go index 3cc82136be..6d72920a8d 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -235,7 +235,7 @@ func newNode( if len(cfg.Builders.Entries) > 0 && usingSSVSigner { // Web3Signer has no request-auth type, so this operator could never contribute an auth // partial — warn once here instead of once per builder per emission in the runner. - logger.Warn("Builders configured with a remote signer: request-auth signing is unsupported there, disabling the direct-builder overlay on this operator (the cluster still reconstructs auths while at most f operators are remote-signing)") + logger.Warn("Builders configured with a remote signer: request-auth signing is unsupported there, dropping this operator's direct-builder entries — its top-level p2p knobs are kept (the cluster still reconstructs auths while at most f operators are remote-signing)") cfg.Builders.Entries = nil } diff --git a/config/config.example.yaml b/config/config.example.yaml index 610878becc..eddc3ae6fa 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -64,8 +64,8 @@ OperatorPrivateKey: # URL, exactly as written) is threshold-signed, so any divergence silently disables that builder. At most 8 # entries. Monetary values are Gwei. The top-level MinBid/BuilderBoostFactor apply to p2p (gossiped) bids # and, per keymanager-APIs#88, are the default for any entry that omits its own. The bid-selection knobs -# are validated but NOT YET HONORED — they take effect with the produceBlockV4 POST migration -# (beacon-APIs#630). See docs/EXTERNAL_BUILDERS.md. +# are sent on the produceBlockV4 POST and honored by beacon nodes that implement it (beacon-APIs#630); +# against an older node the request falls back to GET and the knobs are inactive. See docs/EXTERNAL_BUILDERS.md. # Builders: # MinBid: 0 # p2p-bid floor + default for entries below; 0 = no floor # BuilderBoostFactor: 100 # p2p-bid multiplier % + default; 0 = always local, 100 = neutral diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index a3f600f114..68142747ad 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -38,10 +38,31 @@ byte-identical `data`: differences between operators' `URL` values break the quorum unless an explicit shared `AuthData` is set. - The unsigned knobs (`MinBid`, `BuilderBoostFactor`, `MaxExecutionPayment`) don't affect signing, but divergence makes the cluster's effective bid policy depend on which operator leads the round — keep them - identical too. They take effect with the produceBlockV4 POST migration (beacon-APIs#630). + identical too. They are sent on the `produceBlockV4` POST and honored by beacon nodes that implement it + (beacon-APIs#630); against an older node the request falls back to GET and the knobs are inactive. - Remote-signing operators (Web3Signer) cannot produce request-auth partials — there is no request-auth - signing type there yet. A node with `Builders` entries set and a remote signer warns at startup and disables the - overlay locally; the cluster still reconstructs auths while at most `f` operators are remote-signing. + signing type there yet. A node with `Builders` entries set and a remote signer warns at startup and drops + its direct-builder **entries** (keeping the top-level p2p knobs); the cluster still reconstructs auths + while at most `f` operators are remote-signing. + +### How it works + +Once at least one builder is configured, three things happen around a proposal — all opt-in, all falling +back to the enshrined flow (gossiped bids / self-build) on any failure: + +1. **Ahead-of-time auth.** Across the proposer lookahead, the §5 dispatcher threshold-signs one + `BuilderRequestAuth{data, proposal_slot}` per builder and reconstructs it from operator partials into a + per-slot cache. +2. **Bid request.** At proposal time the node sends `produceBlockV4` as a POST (beacon-APIs#630) carrying + the config plus the reconstructed auths; the beacon node authenticates to each builder and returns the + winning block. When a builder-API bid wins, the node echoes `Eth-Builder-Url` on publish so the beacon + node forwards the block to that builder. +3. **Preferences.** On each reconstruction the node also submits the ahead-of-time + `submitBuilderPreferences` (the `MaxExecutionPayment` cap) through its own beacon node, so the builder + holds it before the bid request arrives. + +A cluster with no `Builders` config keeps the plain enshrined GET produce. A beacon node that predates the +#630 POST answers it with 404/405; the node falls back to the legacy GET (knobs inactive) for that node. ## How to use diff --git a/protocol/v2/ssv/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go index 25ff2b22e4..1ced643f6c 100644 --- a/protocol/v2/ssv/request_auth_cache.go +++ b/protocol/v2/ssv/request_auth_cache.go @@ -11,10 +11,9 @@ import ( // RequestAuthCache holds, per proposal slot, the threshold-reconstructed SignedBuilderRequestAuth for // each configured builder relationship (issue #2962 B1), keyed by gloas.BuilderIdentity. The §5 -// slot sub-runners write on reconstruction quorum; there is no reader yet — the §4 produce path -// (and later the ahead-of-time submitBuilderPreferences) becomes one with the produceBlockV4 POST -// migration. One instance per validator, in package ssv beside ProposedBlockRoots for the same -// import-cycle reason. Safe for concurrent use. +// slot sub-runners write on reconstruction quorum; the §4 produce path reads it to attach the auths +// to the produceBlockV4 POST body (beacon-APIs#630). One instance per validator, in package ssv beside +// ProposedBlockRoots for the same import-cycle reason. Safe for concurrent use. type RequestAuthCache struct { // currentSlot anchors eviction: writes land up to a proposer lookahead ahead of their slot, so // pruning by the clock — not by the last-written slot — is what keeps one future slot's auths diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index aca763a958..e42f417121 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -153,7 +153,7 @@ var ( meter.Int64Counter( observability.InstrumentName(observabilityNamespace, "builder_preferences.submits"), metric.WithUnit("{submit}"), - metric.WithDescription("ahead-of-time Gloas builder-preferences submissions to the beacon node (issue #2962 phase 3), by outcome"))) + metric.WithDescription("ahead-of-time Gloas builder-preferences submit calls to the beacon node (issue #2962 phase 3), by outcome — batch-level, one call per reconstructed auth root"))) ) func recordSuccessfulSubmission(ctx context.Context, count int64, epoch phase0.Epoch, role spectypes.BeaconRole) { @@ -223,8 +223,11 @@ func recordProposalAuthUnavailable(ctx context.Context, count int) { requestAuthUnavailableCounter.Add(ctx, int64(count)) } -// recordBuilderPreferencesSubmit counts an ahead-of-time builder-preferences submission (issue #2962 -// phase 3) by outcome. It is best-effort at the caller, so a failure is a health signal, not a duty failure. +// recordBuilderPreferencesSubmit counts an ahead-of-time builder-preferences submit call (issue #2962 +// phase 3) by outcome. It is batch-level — one call per reconstructed auth root, across the builders +// sharing it — so a non-2xx (including a beacon-APIs#630 partial 400, where the other entries were still +// accepted) books the whole call a failure; the per-entry IndexedErrorMessage rides the caller's warn log. +// Best-effort at the caller, so a failure is a health signal, not a duty failure. func recordBuilderPreferencesSubmit(ctx context.Context, success bool) { outcome := "failure" if success { diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 1405111704..718b15e24f 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -328,15 +328,20 @@ func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Log }, nil } -// gloasBuilderConfig assembles the produceBlockV4 POST body for the direct-builder overlay from the -// cluster config and the per-slot reconstructed auths, or returns nil when no builders are configured (the -// enshrined GET path). Builders whose auth missed quorum this slot are omitted and counted for the E1 -// auth-unavailable signal; the top-level p2p knobs are always carried, so a configured cluster still POSTs. +// gloasBuilderConfig assembles the produceBlockV4 POST body from the cluster's direct-builder config and +// the per-slot reconstructed auths (beacon-APIs#630), or nil when nothing is configured (→ the enshrined +// GET). Builders whose auth missed quorum this slot are omitted and counted for the E1 auth-unavailable +// signal; the top-level p2p knobs are always carried. The goclient falls back to GET per beacon node that +// predates #630. func (r *ProposerRunner) gloasBuilderConfig(ctx context.Context, slot phase0.Slot) *gloas.ProduceBuilderConfig { - if r.requestAuthCache == nil || len(r.builders.Entries) == 0 { + if !r.builders.Configured() { return nil } - cfg, authUnavailable := gloas.BuildProduceConfig(r.builders, r.requestAuthCache.Get(slot)) + var auths map[string]*gloas.SignedBuilderRequestAuth + if r.requestAuthCache != nil { + auths = r.requestAuthCache.Get(slot) + } + cfg, authUnavailable := gloas.BuildProduceConfig(r.builders, auths) if authUnavailable > 0 { recordProposalAuthUnavailable(ctx, authUnavailable) } diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index 9679d984d2..e589fd0a4d 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -180,7 +180,9 @@ func (r *proposerPreferencesSlotRunner) processRequestAuthPartial(ctx context.Co // (issue #2962 phase 3, beacon-APIs#630): one BuilderPreferencesEntry per builder sharing the auth, each // carrying the proposer pubkey, the builder URL, and the configured max-execution-payment cap. Every // operator submits via its own beacon node — the builder dedupes per proposer per slot. Best-effort: a -// failure never disturbs the §5/auth flow, only its metric and log. +// failure never disturbs the §5/auth flow, only its metric and log. The submit runs inline on the §5 +// message-queue path; the wait is bounded by commonTimeout, per-validator, and epoch-ahead of the +// proposal, so it stays off the critical path. func (r *proposerPreferencesSlotRunner) submitBuilderPreferences(ctx context.Context, logger *zap.Logger, signed *gloas.SignedBuilderRequestAuth, builders []frozenBuilderRef) { pubkey := phase0.BLSPubKey(r.GetShare().ValidatorPubKey) entries := make([]*gloas.BuilderPreferencesEntry, 0, len(builders)) diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 4bd6568b05..5ee9cfe3ab 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -44,8 +44,8 @@ func BuilderIdentity(url string, authData []byte) string { // divergence is consensus-safe but leaves the effective policy to whoever leads the round. See // docs/EXTERNAL_BUILDERS.md. // -// Today only Entries' URL and AuthData are consumed (the request-auth signing round); the unsigned -// knobs and the resolution below take effect with the produceBlockV4 POST migration (beacon-APIs#630). +// Entries' URL/AuthData drive the request-auth signing round (§5); the top-level knobs and the +// per-entry resolution below drive the produceBlockV4 POST body (§4, beacon-APIs#630). type BuilderConfig struct { // MinBid is the minimum total payment (Gwei) accepted from a p2p bid, and the default for any // Entry that omits its own MinBid. Zero means no floor. @@ -92,6 +92,14 @@ func (c *BuilderConfig) EffectiveBoostFactor() uint64 { return *c.BuilderBoostFactor } +// Configured reports whether the operator set any direct-builder configuration — entries or the top-level +// p2p knobs (MinBid / BuilderBoostFactor); the zero value is false. §4 POSTs produceBlockV4 when true and +// uses the enshrined GET when false (imposing no proposer knobs on an unconfigured cluster) — so a +// knobs-only config is honored, and clearing entries for a remote signer keeps the p2p knobs. +func (c *BuilderConfig) Configured() bool { + return len(c.Entries) > 0 || c.MinBid != 0 || c.BuilderBoostFactor != nil +} + // AuthDataBytes returns the exact bytes signed into BuilderRequestAuth.Data for this builder: the // decoded AuthData, or the UTF-8 bytes of URL when AuthData is omitted. func (e *BuilderEntry) AuthDataBytes() ([]byte, error) { diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index 7a1fae7c72..3a554ce260 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -47,6 +47,14 @@ func TestBuilderEntry_Effective(t *testing.T) { require.Equal(t, uint64(5), (&BuilderEntry{}).EffectiveMinBid(cfg)) } +func TestBuilderConfig_Configured(t *testing.T) { + require.False(t, (&BuilderConfig{}).Configured(), "zero value is not configured -> §4 uses the enshrined GET") + require.True(t, (&BuilderConfig{Entries: []BuilderEntry{{URL: "https://x.example"}}}).Configured(), "entries -> configured") + require.True(t, (&BuilderConfig{MinBid: 1}).Configured(), "top-level MinBid -> configured (knobs-only)") + zero := uint64(0) + require.True(t, (&BuilderConfig{BuilderBoostFactor: &zero}).Configured(), "explicit boost 0 -> configured (not the nil zero value)") +} + func TestValidateBuilderConfig(t *testing.T) { validate := func(entries ...BuilderEntry) error { return ValidateBuilderConfig(BuilderConfig{Entries: entries}) From 9279bbd2d9ac63287e4eacc5cc1e0ab2fe609b2d Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 12:23:53 +0300 Subject: [PATCH 136/150] gloas: carry builder_boost_factor on the produce GET fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review of the direct-builder overlay (issue #2962): - The pre-#630 GET produce (beacon-APIs#580) accepts builder_boost_factor with the same semantics as #630's top-level knob (builder bids weighed against the local payload at 100). Append it to the per-node GET fallback in requestGloasBeaconBlock — only when the cluster configured the overlay (builderConfig != nil, i.e. BuilderConfig.Configured()), so it becomes the one knob that works against today's GET-only beacon nodes. min_bid and the per-builder inputs have no GET counterpart and stay POST-only. - Document the produce telemetry method label as an accepted, transitional inaccuracy: the route is labeled by its primary method (POST when configured), so a per-node GET fallback is still counted under POST. The honest fixes both cost more than the cosmetic warrants — recordRequest's maybeFallback means node-level fallback, and threading the used method out bloats the generic firstClientResult. - Correct the now-stale "knobs inactive on the GET fallback" wording in config.example.yaml and EXTERNAL_BUILDERS.md to match: BuilderBoostFactor still applies on the fallback GET; MinBid and the per-entry knobs are POST-only. --- beacon/goclient/gloas_proposer.go | 12 ++++++++++-- beacon/goclient/gloas_proposer_test.go | 11 ++++++++--- config/config.example.yaml | 3 ++- docs/EXTERNAL_BUILDERS.md | 6 ++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 1e9e910637..0ab6288bfe 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -36,6 +36,9 @@ type gloasBlockResult struct { // (beacon-APIs#630, direct-builder overlay), falling back per beacon node to the GET for nodes that predate // it; the returned string is the winning builder's Eth-Builder-Url, if any. func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { + // Telemetry labels the route by its primary method: POST when the cluster configured the overlay, + // else GET. A per-node GET fallback (a beacon node predating beacon-APIs#630) is still counted under + // POST — an accepted, transitional inaccuracy that disappears as beacon nodes adopt the #630 POST. httpMethod := http.MethodGet if builderConfig != nil { httpMethod = http.MethodPost @@ -71,7 +74,8 @@ func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.Sig } // requestGloasBeaconBlock produces one Gloas block from a single beacon node. With a builderConfig it POSTs -// the beacon-APIs#630 body and, only on a 404/405 (the node predates the POST), retries as the GET. +// the beacon-APIs#630 body and, only on a 404/405 (the node predates the POST), retries as the GET carrying +// builder_boost_factor (the sole knob the pre-#630 GET also honors). func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (gloasBlockResult, error) { // Graffiti must be a full 32-byte value in the query — lighthouse rejects a short one with 400 // "Invalid query string" (mirror the mature GetBeaconBlock path which pads to [32]byte). @@ -87,7 +91,11 @@ func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, if !isMethodOrPathMissing(err) { return gloasBlockResult{}, err } - // The node doesn't implement the produceBlockV4 POST yet; fall through to the GET. + // The node predates the produceBlockV4 POST; fall back to the GET, still carrying + // builder_boost_factor — the one knob the pre-#630 GET (beacon-APIs#580) also honors, same + // semantics (builder bids weighed against the local payload at 100). min_bid and the per-builder + // inputs have no GET counterpart, so they are POST-only. + url += fmt.Sprintf("&builder_boost_factor=%d", builderConfig.BuilderBoostFactor) } respBody, header, err := gloasHTTPDo(ctx, http.MethodGet, url, nil, "", nil) diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 680b344de5..502cab1035 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -23,12 +23,13 @@ func TestRequestGloasBeaconBlock(t *testing.T) { blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) - var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotIncludePayload string + var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotIncludePayload, gotBoost string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path gotRandao = r.URL.Query().Get("randao_reveal") gotGraffiti = r.URL.Query().Get("graffiti") gotIncludePayload = r.URL.Query().Get("include_payload") + gotBoost = r.URL.Query().Get("builder_boost_factor") gotAccept = r.Header.Get("Accept") _, _ = w.Write(blockSSZ) })) @@ -42,6 +43,7 @@ func TestRequestGloasBeaconBlock(t *testing.T) { require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th // graffiti is padded to a full 32-byte value before hex-encoding (lighthouse rejects a short one). require.Equal(t, "0x02"+strings.Repeat("00", 31), gotGraffiti) + require.Empty(t, gotBoost, "no knobs (builder_boost_factor) on an unconfigured cluster's GET") require.Equal(t, "application/octet-stream", gotAccept) require.Equal(t, phase0.Slot(7), got.block.Slot) } @@ -83,26 +85,29 @@ func TestRequestGloasBeaconBlock_POST(t *testing.T) { } // A beacon node that predates the produceBlockV4 POST answers it with 404; produce then retries that node -// as the legacy GET, transparently. +// as the legacy GET, carrying builder_boost_factor (the one knob the pre-#630 GET also honors). func TestRequestGloasBeaconBlock_POSTFallbackToGET(t *testing.T) { blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) var methods []string + var getBoost string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { methods = append(methods, r.Method) if r.Method == http.MethodPost { w.WriteHeader(http.StatusNotFound) // node predates beacon-APIs#630 return } + getBoost = r.URL.Query().Get("builder_boost_factor") _, _ = w.Write(blockSSZ) })) defer srv.Close() - cfg := &gloas.ProduceBuilderConfig{BuilderBoostFactor: 100} + cfg := &gloas.ProduceBuilderConfig{BuilderBoostFactor: 150} got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, cfg) require.NoError(t, err) require.Equal(t, []string{http.MethodPost, http.MethodGet}, methods, "POST 404 falls back to GET") + require.Equal(t, "150", getBoost, "the fallback GET carries the configured builder_boost_factor") require.Equal(t, phase0.Slot(7), got.block.Slot) require.Empty(t, got.builderURL) } diff --git a/config/config.example.yaml b/config/config.example.yaml index eddc3ae6fa..7013ddf4d5 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -65,7 +65,8 @@ OperatorPrivateKey: # entries. Monetary values are Gwei. The top-level MinBid/BuilderBoostFactor apply to p2p (gossiped) bids # and, per keymanager-APIs#88, are the default for any entry that omits its own. The bid-selection knobs # are sent on the produceBlockV4 POST and honored by beacon nodes that implement it (beacon-APIs#630); -# against an older node the request falls back to GET and the knobs are inactive. See docs/EXTERNAL_BUILDERS.md. +# against an older node the request falls back to GET, where only BuilderBoostFactor still applies (MinBid +# and the per-entry knobs are POST-only). See docs/EXTERNAL_BUILDERS.md. # Builders: # MinBid: 0 # p2p-bid floor + default for entries below; 0 = no floor # BuilderBoostFactor: 100 # p2p-bid multiplier % + default; 0 = always local, 100 = neutral diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index 68142747ad..512bea69d4 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -39,7 +39,8 @@ byte-identical `data`: - The unsigned knobs (`MinBid`, `BuilderBoostFactor`, `MaxExecutionPayment`) don't affect signing, but divergence makes the cluster's effective bid policy depend on which operator leads the round — keep them identical too. They are sent on the `produceBlockV4` POST and honored by beacon nodes that implement it - (beacon-APIs#630); against an older node the request falls back to GET and the knobs are inactive. + (beacon-APIs#630); against an older node the request falls back to GET, where only `BuilderBoostFactor` + still applies (the GET's long-standing knob) — `MinBid` and the per-entry knobs are POST-only. - Remote-signing operators (Web3Signer) cannot produce request-auth partials — there is no request-auth signing type there yet. A node with `Builders` entries set and a remote signer warns at startup and drops its direct-builder **entries** (keeping the top-level p2p knobs); the cluster still reconstructs auths @@ -62,7 +63,8 @@ back to the enshrined flow (gossiped bids / self-build) on any failure: holds it before the bid request arrives. A cluster with no `Builders` config keeps the plain enshrined GET produce. A beacon node that predates the -#630 POST answers it with 404/405; the node falls back to the legacy GET (knobs inactive) for that node. +#630 POST answers it with 404/405; the node falls back to the legacy GET for that node, still carrying +`BuilderBoostFactor` (the one knob that GET honors) — `MinBid` and the per-entry knobs are POST-only. ## How to use From ea641ee4ef6ba21903ae72db6c59b5103c27a21c Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 13:06:07 +0300 Subject: [PATCH 137/150] message/validation: admit the Gloas runner roles in validRoleUnion (#2999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork-independent validRoleUnion gate (added on stage after the branch had already extended the fork-gated validRoleAtSlot) was missing the three Gloas roles, so every peer message with RolePTCAttester(7), RoleProposerPreferences(8) or RoleEnvelopeProposer(9) was REJECTed — with a peer penalty — before executor resolution: no SIP #94 par.3/par.5/par.6 duty could reach quorum, while each node's own messages stayed healthy via the validateSelf bypass. A semantic (non-textual) merge conflict: git had nothing to flag when the stage absorption brought the union in. Add the roles to the union, cover the three roles through validateSSVMessage (the path the existing Gloas validation tests bypassed), and add the missing drift guard: a sweep asserting every role validRoleAtSlot admits at any slot is present in validRoleUnion, so the next role cannot drift between the two registries. --- message/validation/signed_ssv_message.go | 5 +- message/validation/signed_ssv_message_test.go | 66 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 message/validation/signed_ssv_message_test.go diff --git a/message/validation/signed_ssv_message.go b/message/validation/signed_ssv_message.go index fdfdc98a74..b7e769c00c 100644 --- a/message/validation/signed_ssv_message.go +++ b/message/validation/signed_ssv_message.go @@ -144,7 +144,10 @@ func (mv *messageValidator) validRoleUnion(roleType spectypes.RunnerRole) bool { spectypes.RoleVoluntaryExit, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator, - ssvtypes.RoleSyncCommitteeContribution: + ssvtypes.RoleSyncCommitteeContribution, + spectypes.RolePTCAttester, + spectypes.RoleProposerPreferences, + spectypes.RoleEnvelopeProposer: return true default: return false diff --git a/message/validation/signed_ssv_message_test.go b/message/validation/signed_ssv_message_test.go new file mode 100644 index 0000000000..a630a40b65 --- /dev/null +++ b/message/validation/signed_ssv_message_test.go @@ -0,0 +1,66 @@ +package validation + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/networkconfig" +) + +// The three Gloas runner roles must pass the fork-independent validRoleUnion gate in +// validateSSVMessage (issue #2999: they were REJECTed there — with a peer penalty — before the +// per-slot validRoleAtSlot ever ran, so no §3/§5/§6 duty could reach quorum while every node's +// own messages looked healthy via the validateSelf bypass). +func TestValidateSSVMessage_GloasRolesPassRoleUnion(t *testing.T) { + mv := &messageValidator{} + for _, role := range []spectypes.RunnerRole{ + spectypes.RolePTCAttester, + spectypes.RoleProposerPreferences, + spectypes.RoleEnvelopeProposer, + } { + msg := &spectypes.SSVMessage{ + MsgType: spectypes.SSVPartialSignatureMsgType, + MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), role), + Data: []byte{1}, + } + require.NoError(t, mv.validateSSVMessage(msg), "role %d must pass the role union", role) + } + + // Negative control: an out-of-union role still REJECTs at the same gate. + bad := &spectypes.SSVMessage{ + MsgType: spectypes.SSVPartialSignatureMsgType, + MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RunnerRole(999)), + Data: []byte{1}, + } + require.ErrorIs(t, mv.validateSSVMessage(bad), ErrInvalidRole) +} + +// Lockstep between the two role registries: any role validRoleAtSlot admits at any slot must be +// in validRoleUnion, or the union gate rejects it before the per-slot check can ever run. This is +// the drift guard issue #2999 lacked — the union arrived from stage after the branch had already +// extended the fork-gated check, and nothing tied the two together. The sweep bound mirrors the +// role sweeps in protocol/v2/message and observability/utils: headroom over the spec's max value. +func TestValidRoleUnion_LockstepWithValidRoleAtSlot(t *testing.T) { + const gloasEpoch = 100 + netCfg := networkconfig.TestNetworkWithGloas(gloasEpoch) + mv := &messageValidator{netCfg: netCfg} + + slots := []phase0.Slot{ + 0, // earliest fork era in the test config + phase0.Slot(uint64(gloasEpoch-1) * netCfg.SlotsPerEpoch), // pre-Gloas + phase0.Slot(uint64(gloasEpoch) * netCfg.SlotsPerEpoch), // Gloas + } + for i := 0; i <= 31; i++ { + role := spectypes.RunnerRole(i) + for _, slot := range slots { + if mv.validRoleAtSlot(role, slot) { + require.True(t, mv.validRoleUnion(role), + "role %d is admitted by validRoleAtSlot at slot %d but missing from validRoleUnion", role, slot) + } + } + } +} From b878ae27a8cbeced2ab6c1da84d9f5e1b3c68e6d Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 13:06:08 +0300 Subject: [PATCH 138/150] message: add the Gloas runner roles to the exporter role-string mappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunnerRoleToString/FromString were missing PTC_ATTESTER, PROPOSER_PREFERENCES and ENVELOPE_PROPOSER — the same registry-drift class as the validRoleUnion gap (#2999), caught here by the existing round-trip sweep and the observability lockstep test the moment the pinned spec gave the roles real String() values. The strings follow the established trimmed-_RUNNER convention, keeping the mappers in lockstep with ssvtypes.RunnerRoleToString's spec passthrough. --- protocol/v2/message/msg.go | 15 +++++++++++++++ protocol/v2/message/msg_test.go | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/protocol/v2/message/msg.go b/protocol/v2/message/msg.go index ad0b20e4ba..b3fc76130b 100644 --- a/protocol/v2/message/msg.go +++ b/protocol/v2/message/msg.go @@ -22,6 +22,9 @@ const ( roleVoluntaryExit = "VOLUNTARY_EXIT" roleCommittee = "COMMITTEE" roleAggregatorCommittee = "AGGREGATOR_COMMITTEE" + rolePTCAttester = "PTC_ATTESTER" + roleProposerPreferences = "PROPOSER_PREFERENCES" + roleEnvelopeProposer = "ENVELOPE_PROPOSER" ) // MsgTypeToString extension for spec msg type. convert spec msg type to string @@ -92,6 +95,12 @@ func RunnerRoleFromString(s string) (spectypes.RunnerRole, error) { return spectypes.RoleValidatorRegistration, nil case roleVoluntaryExit: return spectypes.RoleVoluntaryExit, nil + case rolePTCAttester: + return spectypes.RolePTCAttester, nil + case roleProposerPreferences: + return spectypes.RoleProposerPreferences, nil + case roleEnvelopeProposer: + return spectypes.RoleEnvelopeProposer, nil default: return 0, fmt.Errorf("unknown role: %s", s) } @@ -134,6 +143,12 @@ func RunnerRoleToString(r spectypes.RunnerRole) string { return roleValidatorRegistration case spectypes.RoleVoluntaryExit: return roleVoluntaryExit + case spectypes.RolePTCAttester: + return rolePTCAttester + case spectypes.RoleProposerPreferences: + return roleProposerPreferences + case spectypes.RoleEnvelopeProposer: + return roleEnvelopeProposer default: return fmt.Sprintf("unknown(%d)", r) } diff --git a/protocol/v2/message/msg_test.go b/protocol/v2/message/msg_test.go index 83946c8964..cef1c110fd 100644 --- a/protocol/v2/message/msg_test.go +++ b/protocol/v2/message/msg_test.go @@ -55,6 +55,9 @@ func TestRunnerRoleFromString(t *testing.T) { {name: "sync committee contribution", input: "SYNC_COMMITTEE_CONTRIBUTION", expected: ssvtypes.RoleSyncCommitteeContribution}, {name: "validator registration", input: "VALIDATOR_REGISTRATION", expected: spectypes.RoleValidatorRegistration}, {name: "voluntary exit", input: "VOLUNTARY_EXIT", expected: spectypes.RoleVoluntaryExit}, + {name: "ptc attester", input: "PTC_ATTESTER", expected: spectypes.RolePTCAttester}, + {name: "proposer preferences", input: "PROPOSER_PREFERENCES", expected: spectypes.RoleProposerPreferences}, + {name: "envelope proposer", input: "ENVELOPE_PROPOSER", expected: spectypes.RoleEnvelopeProposer}, {name: "sync committee (deprecated bare role) errors", input: "SYNC_COMMITTEE", hasError: true}, {name: "unknown role errors", input: "NOT_A_ROLE", hasError: true}, {name: "empty string errors", input: "", hasError: true}, @@ -86,6 +89,9 @@ func TestRunnerRoleToString(t *testing.T) { {name: "sync committee contribution", role: ssvtypes.RoleSyncCommitteeContribution, expected: "SYNC_COMMITTEE_CONTRIBUTION"}, {name: "validator registration", role: spectypes.RoleValidatorRegistration, expected: "VALIDATOR_REGISTRATION"}, {name: "voluntary exit", role: spectypes.RoleVoluntaryExit, expected: "VOLUNTARY_EXIT"}, + {name: "ptc attester", role: spectypes.RolePTCAttester, expected: "PTC_ATTESTER"}, + {name: "proposer preferences", role: spectypes.RoleProposerPreferences, expected: "PROPOSER_PREFERENCES"}, + {name: "envelope proposer", role: spectypes.RoleEnvelopeProposer, expected: "ENVELOPE_PROPOSER"}, {name: "unknown role", role: spectypes.RunnerRole(999), expected: "unknown(999)"}, } From 93bfe874cbf51e1958a3695758b5f755fa2ae799 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 13:06:09 +0300 Subject: [PATCH 139/150] exporter/dutytracer: explicitly skip the Gloas duty types The trace store has no schema for the SIP #94 par.3/par.5/par.6 duties, and with message validation now admitting their roles on the wire (#2999) the collector would error per message in toBNRole. Skip the three roles explicitly at the collect entry instead; tracing them is deliberate future exporter work. The toBNRole table now pins the intentional non-mapping. --- exporter/dutytracer/collector.go | 11 +++++++++++ exporter/dutytracer/collector_test.go | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/exporter/dutytracer/collector.go b/exporter/dutytracer/collector.go index 33929da4d4..9d9096be35 100644 --- a/exporter/dutytracer/collector.go +++ b/exporter/dutytracer/collector.go @@ -868,6 +868,17 @@ func (c *Collector) wrapVerifyPartialSigErr(ctx partialSigVerifyCtx, pSigMessage } func (c *Collector) collect(ctx context.Context, msg *queue.SSVMessage, verifySig func(*spectypes.PartialSignatureMessages) error) error { + // The three Gloas duty types (SIP #94 §3 PTC, §5 proposer preferences, §6 payload envelope) + // are not traced yet — the trace store has no schema for them — so skip their messages + // explicitly instead of erroring per message in toBNRole now that message validation admits + // the roles on the wire (issue #2999). Tracing them is deliberate future exporter work. + switch msg.MsgID.GetRoleType() { + case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeProposer: + return nil + default: + // Other roles fall through to tracing below. + } + start := time.Now() //nolint:gosec startTime := uint64(start.UnixMilli()) diff --git a/exporter/dutytracer/collector_test.go b/exporter/dutytracer/collector_test.go index b850f6cf0e..0a27450509 100644 --- a/exporter/dutytracer/collector_test.go +++ b/exporter/dutytracer/collector_test.go @@ -1383,6 +1383,11 @@ func TestValidatorDutyTrace_toBNRole(t *testing.T) { {spectypes.RoleValidatorRegistration, spectypes.BNRoleValidatorRegistration, false}, {spectypes.RoleVoluntaryExit, spectypes.BNRoleVoluntaryExit, false}, {spectypes.RoleCommittee, spectypes.BNRoleUnknown, true}, + // The Gloas duty types are intentionally unmapped: collect skips them before toBNRole + // (no trace-store schema yet), so reaching this error would mean the skip regressed. + {spectypes.RolePTCAttester, spectypes.BNRoleUnknown, true}, + {spectypes.RoleProposerPreferences, spectypes.BNRoleUnknown, true}, + {spectypes.RoleEnvelopeProposer, spectypes.BNRoleUnknown, true}, } for _, test := range tests { From ce68554e261e2b25c06c7d96a1b7011b2f17632b Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 13:18:22 +0300 Subject: [PATCH 140/150] gloas: point the remote-signer TODO(gloas) markers at the tracking issue The four Gloas domains that Web3Signer can't yet sign (PTC payload attestation, proposer preferences, execution-payload envelope, builder request-auth) each return an explicit "not supported" error with a TODO(gloas). Reference the new tracking issue (#3000) from each so the code points at the actionable tracker. --- ssvsigner/ekm/remote_key_manager.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index c89d8413be..980c0ccc7d 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -406,27 +406,27 @@ func (km *RemoteKeyManager) prepareSignRequest( // Gloas (ePBS) PTC payload attestations have no Web3Signer request type, so a remote-signing // operator can't participate in PTC. Bounded — the cluster reconstructs while ≤ f operators // are remote-signing — but those operators must sign PTC-assigned validators locally. - // TODO(gloas): route PTC signing through Web3Signer once it adds a payload-attestation type. + // TODO(gloas): route PTC signing through Web3Signer once it adds a payload-attestation type (#3000). return web3signer.SignRequest{}, phase0.Root{}, errors.New("payload attestation signing is not supported by the remote signer: Web3Signer has no payload-attestation type, use local signing for PTC-assigned validators") case spectypes.DomainProposerPreferences: // Gloas (ePBS) proposer preferences have no Web3Signer request type, so a remote-signing // operator can't sign them — note this replaces the Web3Signer-supported ValidatorRegistration // at the Gloas fork. Bounded (cluster reconstructs while ≤ f operators are remote-signing), but // those operators must sign locally. - // TODO(gloas): route proposer-preferences signing through Web3Signer once it adds the type. + // TODO(gloas): route proposer-preferences signing through Web3Signer once it adds the type (#3000). return web3signer.SignRequest{}, phase0.Root{}, errors.New("proposer preferences signing is not supported by the remote signer: Web3Signer has no proposer-preferences type, use local signing") case spectypes.DomainBeaconBuilder: // Gloas (ePBS) §6 execution-payload envelopes have no Web3Signer request type, so a remote-signing // operator can't sign them. Bounded (the cluster reconstructs while ≤ f operators are remote-signing), // but those operators must sign self-build envelopes locally. - // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type. + // TODO(gloas): route envelope signing through Web3Signer once it adds an envelope type (#3000). return web3signer.SignRequest{}, phase0.Root{}, errors.New("execution payload envelope signing is not supported by the remote signer: Web3Signer has no envelope type, use local signing for self-build envelopes") case spectypes.DomainBuilderRequestAuth: // The Gloas (ePBS) direct-builder request auth (builder-specs BuilderRequestAuth, issue #2962) has no // Web3Signer request type, so a remote-signing operator can't contribute auth partials. Bounded // (the cluster reconstructs while ≤ f operators are remote-signing), but those operators must // sign locally for the direct-builder overlay to keep its full fault tolerance. - // TODO(gloas): route request-auth signing through Web3Signer once it adds the type. + // TODO(gloas): route request-auth signing through Web3Signer once it adds the type (#3000). return web3signer.SignRequest{}, phase0.Root{}, errors.New("request auth signing is not supported by the remote signer: Web3Signer has no request-auth type, use local signing for the direct-builder overlay") default: return web3signer.SignRequest{}, phase0.Root{}, errors.New("domain unknown") From b68e22a17d49a5b0f856ae3626868f3dc8c996ab Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 15:09:10 +0300 Subject: [PATCH 141/150] gloas: address direct-builder review follow-ups Five items from a review of the direct-builder overlay: - gloasHTTPDo doc: note Eth-Consensus-Version is forced to the Gloas version and not overridable by extraHeaders (no caller overrides it). - Refresh the two role-sweep test bounds: the spec's max RunnerRole is now 9 (RoleEnvelopeProposer), not 6; the 15 headroom stays. - Add a publish-echo test asserting the Eth-Builder-Url header actually reaches the submitGloasBeaconBlock POST (owner-match forwarding). - BuildProduceConfig: fold the two defensive decode-skips into the authUnavailable count so a (validated-at-startup, unreachable) skip can't drop a builder silently. - Rename the now-generic hand-rolled HTTP helpers ptcHTTPClient -> gloasHTTPClient and ptcDo -> jsonDo (they serve all Gloas endpoints, not just PTC); genuinely-PTC identifiers untouched. --- beacon/goclient/attest.go | 4 ++-- beacon/goclient/builder_preferences.go | 4 ++-- beacon/goclient/gloas_proposer.go | 5 +++-- beacon/goclient/gloas_proposer_test.go | 16 ++++++++++++++ beacon/goclient/proposer_preferences.go | 8 +++---- beacon/goclient/ptc.go | 22 +++++++++---------- observability/utils/format_test.go | 2 +- protocol/v2/message/msg_test.go | 2 +- .../v2/types/gloas/produce_builder_config.go | 6 +++-- 9 files changed, 44 insertions(+), 25 deletions(-) diff --git a/beacon/goclient/attest.go b/beacon/goclient/attest.go index bb0519539b..3b51d4bac0 100644 --- a/beacon/goclient/attest.go +++ b/beacon/goclient/attest.go @@ -120,7 +120,7 @@ const gloasAttestationDataPath = "/eth/v1/validator/attestation_data?slot=%d&com func (gc *GoClient) gloasAttestationData(ctx context.Context, slot phase0.Slot) (*phase0.AttestationData, error) { return firstClientResult(ctx, gc, "AttestationData", http.MethodGet, func(ctx context.Context, addr string) (*phase0.AttestationData, error) { - return requestGloasAttestationData(ctx, ptcHTTPClient, addr, slot) + return requestGloasAttestationData(ctx, gloasHTTPClient, addr, slot) }) } @@ -128,7 +128,7 @@ func requestGloasAttestationData(ctx context.Context, httpClient *http.Client, a var resp struct { Data *phase0.AttestationData `json:"data"` } - if err := ptcDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(gloasAttestationDataPath, slot), nil, nil, &resp); err != nil { + if err := jsonDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(gloasAttestationDataPath, slot), nil, nil, &resp); err != nil { return nil, err } if resp.Data == nil { diff --git a/beacon/goclient/builder_preferences.go b/beacon/goclient/builder_preferences.go index 7d20485c2d..9242fae7eb 100644 --- a/beacon/goclient/builder_preferences.go +++ b/beacon/goclient/builder_preferences.go @@ -23,7 +23,7 @@ func (gc *GoClient) SubmitBuilderPreferences(ctx context.Context, preferences [] defer cancel() return gc.multiClientSubmit(ctx, "SubmitBuilderPreferences", func(ctx context.Context, client Client) error { - return submitBuilderPreferences(ctx, ptcHTTPClient, gc.clientAddresses[client], preferences) + return submitBuilderPreferences(ctx, gloasHTTPClient, gc.clientAddresses[client], preferences) }) } @@ -36,7 +36,7 @@ func submitBuilderPreferences(ctx context.Context, httpClient *http.Client, addr return fmt.Errorf("marshal builder preferences: %w", err) } headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} - err = ptcDo(ctx, httpClient, http.MethodPost, addr+builderPreferencesPath, body, headers, nil) + err = jsonDo(ctx, httpClient, http.MethodPost, addr+builderPreferencesPath, body, headers, nil) if isNotFound(err) { return fmt.Errorf("beacon node lacks the gloas builder_preferences endpoint (beacon-APIs#630): %w", err) } diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 0ab6288bfe..8994708534 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -188,7 +188,8 @@ func isMethodOrPathMissing(err error) bool { // gloasHTTPDo issues an SSZ-accepting request to a Gloas endpoint and returns the response body and headers // on a 2xx (see httpDo). A non-nil body is sent with the given contentType and tagged with the Gloas -// consensus version; extraHeaders are applied last. +// consensus version; extraHeaders are applied last, except Eth-Consensus-Version, which is forced to the +// Gloas version (no caller overrides it). func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { if body != nil { merged := make(map[string]string, len(extraHeaders)+1) @@ -198,7 +199,7 @@ func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentTy merged["Eth-Consensus-Version"] = consensusVersionGloas extraHeaders = merged } - return httpDo(ctx, ptcHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) + return httpDo(ctx, gloasHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) } // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 502cab1035..7d26de4fbb 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -148,6 +148,22 @@ func TestSubmitGloasBeaconBlock(t *testing.T) { require.Equal(t, []byte{0x01, 0x02}, gotBody) } +// The Eth-Builder-Url echo (owner-match forwarding, beacon-APIs#630) must reach the publish POST as a +// request header so the beacon node forwards the block to the winning builder. +func TestSubmitGloasBeaconBlock_EchoesBuilderURL(t *testing.T) { + var gotBuilderURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBuilderURL = r.Header.Get("Eth-Builder-Url") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := submitGloasBeaconBlock(context.Background(), srv.URL, []byte{0x01, 0x02}, + map[string]string{"Eth-Builder-Url": "https://builder.example.com"}) + require.NoError(t, err) + require.Equal(t, "https://builder.example.com", gotBuilderURL) +} + func TestGloasOctetStreamHTTP_Non2xxIsError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 7c6548cbde..9506de75af 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -32,7 +32,7 @@ func (gc *GoClient) ProposerDutiesDependentRoot(ctx context.Context, epoch phase dctx, cancel := context.WithTimeout(detached, gc.commonTimeout) defer cancel() return firstClientResult(dctx, gc, "ProposerDutiesDependentRoot", http.MethodGet, func(ctx context.Context, addr string) (phase0.Root, error) { - return requestProposerDutiesDependentRoot(ctx, ptcHTTPClient, addr, epoch) + return requestProposerDutiesDependentRoot(ctx, gloasHTTPClient, addr, epoch) }) }) return root, err @@ -45,7 +45,7 @@ func requestProposerDutiesDependentRoot(ctx context.Context, httpClient *http.Cl DependentRoot phase0.Root `json:"dependent_root"` } url := addr + fmt.Sprintf("/eth/v2/validator/duties/proposer/%d", epoch) - if err := ptcDo(ctx, httpClient, http.MethodGet, url, nil, nil, &resp); err != nil { + if err := jsonDo(ctx, httpClient, http.MethodGet, url, nil, nil, &resp); err != nil { return phase0.Root{}, err } return resp.DependentRoot, nil @@ -59,7 +59,7 @@ func (gc *GoClient) SubmitProposerPreferences(ctx context.Context, preferences [ defer cancel() return gc.multiClientSubmit(ctx, "SubmitProposerPreferences", func(ctx context.Context, client Client) error { - return submitProposerPreferences(ctx, ptcHTTPClient, gc.clientAddresses[client], preferences) + return submitProposerPreferences(ctx, gloasHTTPClient, gc.clientAddresses[client], preferences) }) } @@ -72,7 +72,7 @@ func submitProposerPreferences(ctx context.Context, httpClient *http.Client, add return fmt.Errorf("marshal proposer preferences: %w", err) } headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} - err = ptcDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) + err = jsonDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) if isNotFound(err) { return fmt.Errorf("beacon node lacks the gloas proposer_preferences endpoint (beacon-APIs#608): %w", err) } diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index f73a4d4d69..7b3dbad181 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -29,17 +29,17 @@ const ( consensusVersionGloas = "gloas" ) -// ptcHTTPClient issues the hand-rolled PTC requests; per-call deadlines come from the request context. +// gloasHTTPClient issues the hand-rolled Gloas requests; per-call deadlines come from the request context. // Basic-auth in the (unmasked) beacon address is applied by net/http; custom TLS/client-cert is not — but // the main eth2clienthttp path doesn't configure it either (system-CA https + basic-auth only), so no // regression. Interim surface, retired with the go-eth2-client rebase. -var ptcHTTPClient = &http.Client{} +var gloasHTTPClient = &http.Client{} // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch, from // the first beacon client that responds. func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) { return firstClientResult(ctx, gc, "PayloadAttestationDuties", http.MethodPost, func(ctx context.Context, addr string) ([]*gloas.PTCDuty, error) { - return requestPTCDuties(ctx, ptcHTTPClient, addr, epoch, validatorIndices) + return requestPTCDuties(ctx, gloasHTTPClient, addr, epoch, validatorIndices) }) } @@ -47,7 +47,7 @@ func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.E // first beacon client that responds. func (gc *GoClient) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { return firstClientResult(ctx, gc, "PayloadAttestationData", http.MethodGet, func(ctx context.Context, addr string) (*gloas.PayloadAttestationData, error) { - return requestPayloadAttestationData(ctx, ptcHTTPClient, addr, slot) + return requestPayloadAttestationData(ctx, gloasHTTPClient, addr, slot) }) } @@ -58,7 +58,7 @@ func (gc *GoClient) SubmitPayloadAttestationMessages(ctx context.Context, messag defer cancel() return gc.multiClientSubmit(ctx, "SubmitPayloadAttestationMessages", func(ctx context.Context, client Client) error { - return submitPayloadAttestationMessages(ctx, ptcHTTPClient, gc.clientAddresses[client], messages) + return submitPayloadAttestationMessages(ctx, gloasHTTPClient, gc.clientAddresses[client], messages) }) } @@ -97,7 +97,7 @@ func requestPTCDuties(ctx context.Context, httpClient *http.Client, addr string, var resp struct { Data []*gloas.PTCDuty `json:"data"` } - if err := ptcDo(ctx, httpClient, http.MethodPost, addr+fmt.Sprintf(ptcDutiesPath, epoch), body, nil, &resp); err != nil { + if err := jsonDo(ctx, httpClient, http.MethodPost, addr+fmt.Sprintf(ptcDutiesPath, epoch), body, nil, &resp); err != nil { return nil, err } return resp.Data, nil @@ -108,7 +108,7 @@ func requestPayloadAttestationData(ctx context.Context, httpClient *http.Client, var resp struct { Data *gloas.PayloadAttestationData `json:"data"` } - if err := ptcDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(payloadAttestationDataPath, slot), nil, nil, &resp); err != nil { + if err := jsonDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(payloadAttestationDataPath, slot), nil, nil, &resp); err != nil { return nil, err } if resp.Data == nil { @@ -124,7 +124,7 @@ func submitPayloadAttestationMessages(ctx context.Context, httpClient *http.Clie return fmt.Errorf("marshal payload attestation messages: %w", err) } headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} - return ptcDo(ctx, httpClient, http.MethodPost, addr+payloadAttestationsPath, body, headers, nil) + return jsonDo(ctx, httpClient, http.MethodPost, addr+payloadAttestationsPath, body, headers, nil) } // httpStatusError is a non-2xx response to a hand-rolled Gloas request. It keeps the status code @@ -143,7 +143,7 @@ func (e *httpStatusError) Error() string { // httpDo issues a hand-rolled Gloas HTTP request and returns the response body and headers on a 2xx, or // a *httpStatusError otherwise. accept sets the Accept header; a non-nil body is sent with contentType. -// extraHeaders are applied last. It is the shared core of the JSON (ptcDo) and SSZ (gloasHTTPDo) helpers. +// extraHeaders are applied last. It is the shared core of the JSON (jsonDo) and SSZ (gloasHTTPDo) helpers. func httpDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, accept, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { var reader io.Reader if body != nil { @@ -177,10 +177,10 @@ func httpDo(ctx context.Context, httpClient *http.Client, method, url string, bo return respBody, resp.Header, nil } -// ptcDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil to +// jsonDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil to // ignore the body). A nil body sends no request payload; extraHeaders are applied last. Non-2xx // responses surface as *httpStatusError. -func ptcDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { +func jsonDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { respBody, _, err := httpDo(ctx, httpClient, method, url, body, "application/json", "application/json", extraHeaders) if err != nil { return err diff --git a/observability/utils/format_test.go b/observability/utils/format_test.go index f729bf2f0e..ade3cc0b75 100644 --- a/observability/utils/format_test.go +++ b/observability/utils/format_test.go @@ -108,7 +108,7 @@ func TestRunnerRoleStringMappersLockstep(t *testing.T) { // Sweep beyond the explicit list so a role added to the spec — which FormatRunnerRole // picks up automatically via (RunnerRole).String() but message.RunnerRoleToString's // hand-written switch would miss — fails here instead of drifting silently. The bound - // 15 is headroom over the spec's current max role value (6): roles are appended + // 15 is headroom over the spec's current max role value (9): roles are appended // sequentially, so sweeping a few values past the end catches additions without the // spec exporting a count. Roles the spec does not know return "UNDEFINED" and are // skipped: divergence on genuinely unknown values is intentional (the deprecated Alan diff --git a/protocol/v2/message/msg_test.go b/protocol/v2/message/msg_test.go index cef1c110fd..5833136371 100644 --- a/protocol/v2/message/msg_test.go +++ b/protocol/v2/message/msg_test.go @@ -128,7 +128,7 @@ func TestRunnerRoleFromString_ToString_RoundTrip(t *testing.T) { // and without this sweep FromString could silently stay behind — leaving // CommitteeRunnerRoleFromString to reject the exporter's own emitted string. The // bound and the skip mirror that sweep: 15 is headroom over the spec's current max - // role value (6), and values the spec stringifies as "UNDEFINED" (unused or + // role value (9), and values the spec stringifies as "UNDEFINED" (unused or // deprecated) are covered by the explicit list above instead. for i := 0; i <= 15; i++ { role := spectypes.RunnerRole(i) diff --git a/protocol/v2/types/gloas/produce_builder_config.go b/protocol/v2/types/gloas/produce_builder_config.go index da0842c05a..0ffc7e1d50 100644 --- a/protocol/v2/types/gloas/produce_builder_config.go +++ b/protocol/v2/types/gloas/produce_builder_config.go @@ -110,7 +110,8 @@ func BuildProduceConfig(cfg BuilderConfig, auths map[string]*SignedBuilderReques e := &cfg.Entries[i] data, err := e.AuthDataBytes() if err != nil { - continue // validated at startup; skip defensively + authUnavailable++ // defensive, validated at startup; count, don't drop silently + continue } auth, ok := auths[BuilderIdentity(e.URL, data)] if !ok { @@ -119,7 +120,8 @@ func BuildProduceConfig(cfg BuilderConfig, auths map[string]*SignedBuilderReques } pubkeys, err := e.builderPubKeys() if err != nil { - continue // validated at startup; skip defensively + authUnavailable++ // defensive, validated at startup; count, don't drop silently + continue } out.Builders = append(out.Builders, ProduceBuilderEntry{ URL: e.URL, From effba9a0a2a729b02065e8006636bafbc3f2bcdc Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 15:36:21 +0300 Subject: [PATCH 142/150] gloas: resolve the builder config once, not per read (parse-once) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode auth_data / builder_pubkeys and resolve the effective knobs a single time, at load, into a ResolvedBuilderConfig — the runtime form the §4 produce path and §5 signing round read — instead of re-parsing operator config every proposal. - Add ResolvedBuilderConfig / ResolvedBuilderEntry and ResolveBuilderConfig, which validates + decodes + resolves in one pass. ValidateBuilderConfig is now a thin wrapper over it, removing the duplicate decode validation used to do. - Each runner resolves once per validator: NewProposerRunner (§4) and NewProposerPreferencesRunner (§5, threaded to the per-slot sub-runners). - BuildProduceConfig and the §5 loop read pre-decoded fields; both drop their per-use decode and its unreachable error branch — including the item-4 defensive skips folded in the prior commit. - The §5 signing identity and the §4 auth-cache-lookup identity are now the same stored Identity, so they match by construction rather than by re-decoding identically at each read. Behavior-preserving for valid config (the only reachable state — startup validates); an invalid config now fails fast at runner construction instead of silently dropping entries. No cli/controller or serialization impact. --- protocol/v2/ssv/runner/proposer.go | 16 ++- .../v2/ssv/runner/proposer_preferences.go | 31 +++-- .../proposer_preferences_request_auth.go | 11 +- .../ssv/runner/proposer_preferences_test.go | 4 +- protocol/v2/types/gloas/builder_entry.go | 113 ++++++++++++++---- protocol/v2/types/gloas/builder_entry_test.go | 39 ++++++ .../v2/types/gloas/produce_builder_config.go | 56 ++------- .../gloas/produce_builder_config_test.go | 7 +- 8 files changed, 183 insertions(+), 94 deletions(-) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 718b15e24f..1f524f44a6 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -72,9 +72,10 @@ type ProposerRunner struct { // path, whose context is canceled once the block duty ends. startEnvelopeDuty func(slot phase0.Slot) - // builders is the cluster's direct-builder config (issue #2962, phase 2): the produceBlockV4 POST - // body is assembled from it plus the per-slot reconstructed auths. Empty Entries -> the enshrined GET. - builders gloas.BuilderConfig + // builders is the cluster's direct-builder config, resolved once at construction (issue #2962, phase 2): + // the produceBlockV4 POST body is assembled from it plus the per-slot reconstructed auths. Not + // Configured() -> the enshrined GET. + builders gloas.ResolvedBuilderConfig // requestAuthCache holds the per-slot reconstructed builder auths this operator attaches to the // produceBlockV4 POST. Shared with the §5 dispatcher that writes it; nil pre-Gloas / no overlay. requestAuthCache *ssv.RequestAuthCache @@ -119,6 +120,13 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { return nil, errors.New("must have one share") } + // Decode/resolve the builder config once here (once per validator), so the §4 produce path reads + // pre-decoded values instead of re-parsing config every proposal. Startup already validated it. + builders, err := gloas.ResolveBuilderConfig(opts.Builders) + if err != nil { + return nil, fmt.Errorf("resolve builder config: %w", err) + } + return &ProposerRunner{ BaseRunner: &BaseRunner{ RunnerRoleType: spectypes.RoleProposer, @@ -141,7 +149,7 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { proposerDelayEPBS: opts.ProposerDelayEPBS, proposedBlockRoots: opts.ProposedBlockRoots, startEnvelopeDuty: opts.StartEnvelopeDuty, - builders: opts.Builders, + builders: builders, requestAuthCache: opts.RequestAuthCache, }, nil } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 10e77861e3..6c2c95b017 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -39,6 +39,10 @@ type ProposerPreferencesRunner struct { opts ProposerPreferencesRunnerOptions + // builders is opts.Builders decoded/resolved once here (once per validator), handed to every per-slot + // sub-runner so the §5 signing round reads pre-decoded auth data instead of re-parsing config per slot. + builders []gloas.ResolvedBuilderEntry + // bySlot holds one sub-runner per concurrently-active proposal slot. Accessed only from the // validator's single message-processing goroutine. bySlot map[phase0.Slot]*proposerPreferencesSlotRunner @@ -78,15 +82,22 @@ func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner return nil, fmt.Errorf("must have one share") } + // Decode/resolve the builder config once here (once per validator); startup already validated it. + resolved, err := gloas.ResolveBuilderConfig(opts.Builders) + if err != nil { + return nil, fmt.Errorf("resolve builder config: %w", err) + } + return &ProposerPreferencesRunner{ BaseRunner: &BaseRunner{ RunnerRoleType: spectypes.RoleProposerPreferences, NetworkConfig: opts.NetworkConfig, Share: opts.Share, }, - opts: opts, - bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, - pending: map[phase0.Slot][]*spectypes.PartialSignatureMessages{}, + opts: opts, + builders: resolved.Entries, + bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, + pending: map[phase0.Slot][]*spectypes.PartialSignatureMessages{}, }, nil } @@ -104,7 +115,7 @@ func (r *ProposerPreferencesRunner) StartNewDuty(ctx context.Context, logger *za // submitted preference carries over so an unchanged re-emission stays idempotent (no duplicate // broadcast or beacon-node submit; see executeDuty). slot := validatorDuty.DutySlot() - sub := newProposerPreferencesSlotRunner(r.opts) + sub := newProposerPreferencesSlotRunner(r.opts, r.builders) if prev, ok := r.bySlot[slot]; ok { sub.submittedPreferences = prev.submittedPreferences sub.broadcastPreferences = prev.broadcastPreferences @@ -330,10 +341,10 @@ type proposerPreferencesSlotRunner struct { // stash replay re-seeds the replacement instead, our own first partial included. broadcastPreferences *gloas.ProposerPreferences - // builders is the cluster's direct-builder entry list (issue #2962 B1): for each entry executeDuty - // freezes and threshold-signs a BuilderRequestAuth{data, proposal_slot} alongside the §5 preference. - // Empty disables the request-auth round entirely. - builders []gloas.BuilderEntry + // builders is the cluster's resolved direct-builder entry list (issue #2962 B1): for each entry + // executeDuty freezes and threshold-signs a BuilderRequestAuth{data, proposal_slot} alongside the §5 + // preference. Empty disables the request-auth round entirely. + builders []gloas.ResolvedBuilderEntry requestAuthCache *ssv.RequestAuthCache // requestAuths maps each frozen BuilderRequestAuth's signing root to the object and its builders; @@ -361,7 +372,7 @@ type proposerPreferencesSlotRunner struct { reconstructedAuthRoots map[[32]byte]struct{} } -func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *proposerPreferencesSlotRunner { +func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions, builders []gloas.ResolvedBuilderEntry) *proposerPreferencesSlotRunner { return &proposerPreferencesSlotRunner{ BaseRunner: &BaseRunner{ RunnerRoleType: spectypes.RoleProposerPreferences, @@ -375,7 +386,7 @@ func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions) *pr operatorSigner: opts.OperatorSigner, feeRecipientProvider: opts.FeeRecipientProvider, gasLimit: opts.GasLimit, - builders: opts.Builders.Entries, + builders: builders, requestAuthCache: opts.RequestAuthCache, broadcastAuthRoots: map[[32]byte]struct{}{}, reconstructedAuthRoots: map[[32]byte]struct{}{}, diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index e589fd0a4d..e914721bd1 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -57,21 +57,14 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, r.requestAuths = make(map[[32]byte]*frozenRequestAuth, len(r.builders)) for i := range r.builders { entry := &r.builders[i] - data, err := entry.AuthDataBytes() - if err != nil { - // Unreachable when startup validation ran, but never swallow a real error silently. - logger.Warn("request auth skipped: invalid auth data", - fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) - continue - } - auth := &gloas.BuilderRequestAuth{Data: data, Slot: proposalSlot} + auth := &gloas.BuilderRequestAuth{Data: entry.AuthData, Slot: proposalSlot} root, err := spectypes.ComputeETHSigningRoot(auth, domain) if err != nil { logger.Warn("request auth skipped: could not compute signing root", fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) continue } - ref := frozenBuilderRef{identity: gloas.BuilderIdentity(entry.URL, data), url: entry.URL, maxExecutionPayment: entry.MaxExecutionPayment} + ref := frozenBuilderRef{identity: entry.Identity, url: entry.URL, maxExecutionPayment: entry.MaxExecutionPayment} if frozen, ok := r.requestAuths[root]; ok { // Another entry froze these exact bytes; register the extra relationship on the shared root. frozen.builders = append(frozen.builders, ref) diff --git a/protocol/v2/ssv/runner/proposer_preferences_test.go b/protocol/v2/ssv/runner/proposer_preferences_test.go index b50ab5ec3e..84ce97a488 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_test.go +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -111,8 +111,8 @@ func TestProposerPreferencesRunner_evictPastSlots(t *testing.T) { disp := r.(*ProposerPreferencesRunner) current := netCfg.EstimatedCurrentSlot() - disp.bySlot[current-1] = newProposerPreferencesSlotRunner(disp.opts) - disp.bySlot[current+10] = newProposerPreferencesSlotRunner(disp.opts) + disp.bySlot[current-1] = newProposerPreferencesSlotRunner(disp.opts, disp.builders) + disp.bySlot[current+10] = newProposerPreferencesSlotRunner(disp.opts, disp.builders) disp.evictPastSlots() diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 5ee9cfe3ab..72775cd847 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -5,6 +5,8 @@ import ( "fmt" "net/url" "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" ) // MaxBuilderEntries caps the configured direct-builder list (issue #2962 D2). It is SSV's own @@ -134,50 +136,117 @@ func (e *BuilderEntry) EffectiveBoostFactor(cfg *BuilderConfig) uint64 { return cfg.EffectiveBoostFactor() } -// ValidateBuilderConfig checks a configured builder set: entry cap, non-empty parseable http(s) -// URLs, decodable within-limit auth data, no duplicate (URL, auth data) identities (multiple entries -// MAY share a URL with different auth data), and well-formed optional builder pubkeys. The property -// that matters most — every operator of every shared cluster holding the identical config — cannot -// be checked here and stays an operational requirement (docs/EXTERNAL_BUILDERS.md). -func ValidateBuilderConfig(cfg BuilderConfig) error { +// ResolvedBuilderEntry is a BuilderEntry with its config strings decoded and knobs resolved once, at load +// (ResolveBuilderConfig). Identity — BuilderIdentity(URL, AuthData) — is the single key shared by the §5 +// signing round and the §4 auth-cache lookup, so the two match by construction rather than by each +// re-decoding the config identically on every read. +type ResolvedBuilderEntry struct { + Identity string // BuilderIdentity(URL, AuthData) + URL string // the builder URL, verbatim + AuthData []byte // exact bytes signed into BuilderRequestAuth.Data + BuilderPubKeys []phase0.BLSPubKey // decoded bid-signing keys to pin; empty accepts any builder + MaxExecutionPayment uint64 // Gwei cap on trusted execution-layer payment + MinBid uint64 // resolved (effective) bid floor, Gwei + BoostFactor uint64 // resolved (effective) bid multiplier, percent +} + +// ResolvedBuilderConfig is a BuilderConfig decoded and resolved once (ResolveBuilderConfig) — the runtime +// form the §4 produce path and §5 signing round read, so neither re-parses config on the hot path. +type ResolvedBuilderConfig struct { + MinBid uint64 // top-level p2p-bid floor, Gwei + BoostFactor uint64 // top-level p2p-bid multiplier, percent (resolved; neutral 100 by default) + Entries []ResolvedBuilderEntry + configured bool +} + +// Configured mirrors BuilderConfig.Configured for the resolved form: any entries or top-level p2p knobs. +func (c ResolvedBuilderConfig) Configured() bool { return c.configured } + +// ResolveBuilderConfig validates cfg and decodes it into its runtime form in one pass: it applies every +// builder-set check (entry cap, http(s) URLs, decodable within-limit auth data, no duplicate (URL, auth +// data) identity, 48-byte builder pubkeys) and, per entry, decodes AuthData, resolves the effective knobs, +// and computes the Identity. Decoding here — once, at load — is what lets the §4/§5 read sites drop their +// per-use decode and its unreachable error branch. ValidateBuilderConfig is this, discarding the result. +func ResolveBuilderConfig(cfg BuilderConfig) (ResolvedBuilderConfig, error) { if len(cfg.Entries) > MaxBuilderEntries { - return fmt.Errorf("%d builder entries exceed the %d limit", len(cfg.Entries), MaxBuilderEntries) + return ResolvedBuilderConfig{}, fmt.Errorf("%d builder entries exceed the %d limit", len(cfg.Entries), MaxBuilderEntries) + } + resolved := ResolvedBuilderConfig{ + MinBid: cfg.MinBid, + BoostFactor: cfg.EffectiveBoostFactor(), + configured: cfg.Configured(), + Entries: make([]ResolvedBuilderEntry, 0, len(cfg.Entries)), } seen := make(map[string]struct{}, len(cfg.Entries)) for i := range cfg.Entries { e := &cfg.Entries[i] u, err := url.Parse(e.URL) if err != nil { - return fmt.Errorf("builder entry %d: invalid URL: %w", i, err) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: invalid URL: %w", i, err) } if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: URL must be http(s) with a host, got %q", i, e.URL) } // The URL's bytes are signed when they serve as the default auth data. if e.AuthData == "" && len(e.URL) > MaxBuilderAuthDataSize { - return fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxBuilderAuthDataSize) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: URL is %d bytes, exceeding the %d auth-data limit its bytes default to", i, len(e.URL), MaxBuilderAuthDataSize) } data, err := e.AuthDataBytes() if err != nil { - return fmt.Errorf("builder entry %d: %w", i, err) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: %w", i, err) } if len(data) == 0 { - return fmt.Errorf("builder entry %d: AuthData decodes to zero bytes — omit it to default to the URL bytes", i) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: AuthData decodes to zero bytes — omit it to default to the URL bytes", i) } identity := BuilderIdentity(e.URL, data) if _, dup := seen[identity]; dup { - return fmt.Errorf("builder entry %d: duplicate (URL, AuthData) identity", i) + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: duplicate (URL, AuthData) identity", i) } seen[identity] = struct{}{} - for j, pk := range e.BuilderPubKeys { - b, err := hex.DecodeString(strings.TrimPrefix(pk, "0x")) - if err != nil { - return fmt.Errorf("builder entry %d: BuilderPubKeys[%d]: invalid hex: %w", i, j, err) - } - if len(b) != 48 { - return fmt.Errorf("builder entry %d: BuilderPubKeys[%d]: must be 48 bytes, got %d", i, j, len(b)) - } + pubkeys, err := e.builderPubKeys() + if err != nil { + return ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: %w", i, err) } + resolved.Entries = append(resolved.Entries, ResolvedBuilderEntry{ + Identity: identity, + URL: e.URL, + AuthData: data, + BuilderPubKeys: pubkeys, + MaxExecutionPayment: e.MaxExecutionPayment, + MinBid: e.EffectiveMinBid(&cfg), + BoostFactor: e.EffectiveBoostFactor(&cfg), + }) } - return nil + return resolved, nil +} + +// builderPubKeys parses the entry's 0x-hex BuilderPubKeys into BLS public keys (empty = accept any +// builder). Called by ResolveBuilderConfig, which surfaces any error at load. +func (e *BuilderEntry) builderPubKeys() ([]phase0.BLSPubKey, error) { + if len(e.BuilderPubKeys) == 0 { + return nil, nil + } + out := make([]phase0.BLSPubKey, 0, len(e.BuilderPubKeys)) + for j, s := range e.BuilderPubKeys { + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil { + return nil, fmt.Errorf("BuilderPubKeys[%d]: invalid hex: %w", j, err) + } + if len(b) != 48 { + return nil, fmt.Errorf("BuilderPubKeys[%d]: must be 48 bytes, got %d", j, len(b)) + } + var pk phase0.BLSPubKey + copy(pk[:], b) + out = append(out, pk) + } + return out, nil +} + +// ValidateBuilderConfig reports whether cfg is a well-formed builder set — the ResolveBuilderConfig checks, +// discarding the resolved result; used at startup, before the config reaches the runners. The property that +// matters most — every operator of every shared cluster holding the identical config — cannot be checked +// here and stays an operational requirement (docs/EXTERNAL_BUILDERS.md). +func ValidateBuilderConfig(cfg BuilderConfig) error { + _, err := ResolveBuilderConfig(cfg) + return err } diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index 3a554ce260..e6370e0b38 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -55,6 +55,45 @@ func TestBuilderConfig_Configured(t *testing.T) { require.True(t, (&BuilderConfig{BuilderBoostFactor: &zero}).Configured(), "explicit boost 0 -> configured (not the nil zero value)") } +func TestResolveBuilderConfig(t *testing.T) { + // A valid config decodes and resolves once: Identity, AuthData bytes, effective knobs, pubkeys. + five, nine := uint64(5), uint64(9) + cfg := BuilderConfig{ + MinBid: 5, + BuilderBoostFactor: &nine, + Entries: []BuilderEntry{ + {URL: "https://a.example", MaxExecutionPayment: 250}, // AuthData -> URL bytes; knobs inherited + {URL: "https://b.example", AuthData: "0x0102", MinBid: &five, BuilderPubKeys: []string{"0x" + strings.Repeat("ab", 48)}}, // explicit auth + pinned key + }, + } + resolved, err := ResolveBuilderConfig(cfg) + require.NoError(t, err) + require.True(t, resolved.Configured()) + require.Equal(t, uint64(5), resolved.MinBid) + require.Equal(t, uint64(9), resolved.BoostFactor) + require.Len(t, resolved.Entries, 2) + + a := resolved.Entries[0] + require.Equal(t, BuilderIdentity("https://a.example", []byte("https://a.example")), a.Identity) + require.Equal(t, []byte("https://a.example"), a.AuthData, "omitted AuthData -> URL bytes") + require.Equal(t, uint64(250), a.MaxExecutionPayment) + require.Equal(t, uint64(5), a.MinBid, "inherits config MinBid") + require.Equal(t, uint64(9), a.BoostFactor, "inherits config BoostFactor") + require.Empty(t, a.BuilderPubKeys) + + b := resolved.Entries[1] + require.Equal(t, BuilderIdentity("https://b.example", []byte{0x01, 0x02}), b.Identity) + require.Equal(t, []byte{0x01, 0x02}, b.AuthData, "explicit AuthData decoded from hex") + require.Equal(t, uint64(5), b.MinBid, "entry MinBid wins over config default") + require.Len(t, b.BuilderPubKeys, 1) + + // The zero config resolves to an empty, unconfigured result. + empty, err := ResolveBuilderConfig(BuilderConfig{}) + require.NoError(t, err) + require.False(t, empty.Configured()) + require.Empty(t, empty.Entries) +} + func TestValidateBuilderConfig(t *testing.T) { validate := func(entries ...BuilderEntry) error { return ValidateBuilderConfig(BuilderConfig{Entries: entries}) diff --git a/protocol/v2/types/gloas/produce_builder_config.go b/protocol/v2/types/gloas/produce_builder_config.go index 0ffc7e1d50..314ad46cd1 100644 --- a/protocol/v2/types/gloas/produce_builder_config.go +++ b/protocol/v2/types/gloas/produce_builder_config.go @@ -1,11 +1,9 @@ package gloas import ( - "encoding/hex" "encoding/json" "fmt" "strconv" - "strings" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -73,63 +71,31 @@ func (c *ProduceBuilderConfig) MarshalJSON() ([]byte, error) { }) } -// builderPubKeys parses the entry's 0x-hex BuilderPubKeys into BLS public keys. The list is validated at -// startup (ValidateBuilderConfig), so an error here is defensive. -func (e *BuilderEntry) builderPubKeys() ([]phase0.BLSPubKey, error) { - if len(e.BuilderPubKeys) == 0 { - return nil, nil - } - out := make([]phase0.BLSPubKey, 0, len(e.BuilderPubKeys)) - for _, s := range e.BuilderPubKeys { - b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) - if err != nil { - return nil, fmt.Errorf("invalid builder pubkey hex: %w", err) - } - if len(b) != len(phase0.BLSPubKey{}) { - return nil, fmt.Errorf("builder pubkey must be %d bytes, got %d", len(phase0.BLSPubKey{}), len(b)) - } - var pk phase0.BLSPubKey - copy(pk[:], b) - out = append(out, pk) - } - return out, nil -} - -// BuildProduceConfig resolves cfg against the per-slot reconstructed auths into the produceBlockV4 POST -// body: one entry per configured builder that has a reconstructed auth (auth-less builders are omitted — -// beacon-APIs#630 requires an auth per entry), with per-entry knobs resolved against the config defaults -// (keymanager-APIs#88) and the top-level p2p knobs carried through. It also returns the number of -// configured builders with no reconstructed auth for the slot — the E1 auth-unavailable signal. -func BuildProduceConfig(cfg BuilderConfig, auths map[string]*SignedBuilderRequestAuth) (ProduceBuilderConfig, int) { +// BuildProduceConfig assembles the produceBlockV4 POST body from the resolved cluster config and the +// per-slot reconstructed auths: one entry per configured builder that has a reconstructed auth (auth-less +// builders are omitted — beacon-APIs#630 requires an auth per entry), carrying the resolved per-entry and +// top-level knobs. It also returns the number of configured builders with no reconstructed auth for the +// slot — the E1 auth-unavailable signal. +func BuildProduceConfig(cfg ResolvedBuilderConfig, auths map[string]*SignedBuilderRequestAuth) (ProduceBuilderConfig, int) { out := ProduceBuilderConfig{ MinBid: cfg.MinBid, - BuilderBoostFactor: cfg.EffectiveBoostFactor(), + BuilderBoostFactor: cfg.BoostFactor, } authUnavailable := 0 for i := range cfg.Entries { e := &cfg.Entries[i] - data, err := e.AuthDataBytes() - if err != nil { - authUnavailable++ // defensive, validated at startup; count, don't drop silently - continue - } - auth, ok := auths[BuilderIdentity(e.URL, data)] + auth, ok := auths[e.Identity] if !ok { authUnavailable++ continue } - pubkeys, err := e.builderPubKeys() - if err != nil { - authUnavailable++ // defensive, validated at startup; count, don't drop silently - continue - } out.Builders = append(out.Builders, ProduceBuilderEntry{ URL: e.URL, Auth: auth, - BuilderPubKeys: pubkeys, + BuilderPubKeys: e.BuilderPubKeys, MaxExecutionPayment: e.MaxExecutionPayment, - MinBid: e.EffectiveMinBid(&cfg), - BuilderBoostFactor: e.EffectiveBoostFactor(&cfg), + MinBid: e.MinBid, + BuilderBoostFactor: e.BoostFactor, }) } return out, authUnavailable diff --git a/protocol/v2/types/gloas/produce_builder_config_test.go b/protocol/v2/types/gloas/produce_builder_config_test.go index 6018ebc4c5..f0f2dc1c1a 100644 --- a/protocol/v2/types/gloas/produce_builder_config_test.go +++ b/protocol/v2/types/gloas/produce_builder_config_test.go @@ -23,7 +23,10 @@ func TestBuildProduceConfig(t *testing.T) { BuilderIdentity("https://a.example", []byte("https://a.example")): authA, } - body, unavailable := BuildProduceConfig(cfg, auths) + resolved, err := ResolveBuilderConfig(cfg) + require.NoError(t, err) + + body, unavailable := BuildProduceConfig(resolved, auths) require.Equal(t, 1, unavailable, "builder B has no reconstructed auth for the slot") require.Equal(t, uint64(5), body.MinBid) require.Equal(t, uint64(100), body.BuilderBoostFactor, "nil config boost -> neutral 100") @@ -34,7 +37,7 @@ func TestBuildProduceConfig(t *testing.T) { require.Equal(t, uint64(100), body.Builders[0].BuilderBoostFactor) // No reconstructed auths at all -> empty builders list, every configured builder counted unavailable. - empty, un := BuildProduceConfig(cfg, nil) + empty, un := BuildProduceConfig(resolved, nil) require.Empty(t, empty.Builders) require.Equal(t, 2, un) } From a340e1a37278766ab8d88e1d0e2cf301f84079e6 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 26 Aug 2026 17:18:58 +0300 Subject: [PATCH 143/150] gloas: polish the parse-once follow-ups (docs, receiver, lint) Post-review tidy of the direct-builder overlay: - Document the sharing contract on ResolvedBuilderEntry: its slices are never copied (frozen auths alias AuthData, produce bodies alias BuilderPubKeys), so treat them as immutable. - Align ResolvedBuilderConfig.Configured with (*BuilderConfig).Configured on a pointer receiver. - Point the produce telemetry method-label note at issue #2962 (the POST-first flip tracks resolving it) and drop the clause that duplicated it. - Tighten the resolve-once comments to present-tense statements and dedupe the gloasHTTPDo consensus-version note. - Preallocate submittedURLs in the request-auth test (pinned golangci-lint prealloc finding). --- beacon/goclient/gloas_proposer.go | 7 +++---- protocol/v2/ssv/runner/proposer.go | 4 ++-- protocol/v2/ssv/runner/proposer_preferences.go | 6 +++--- protocol/v2/ssv/runner/request_auth_test.go | 2 +- protocol/v2/types/gloas/builder_entry.go | 10 +++++----- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 8994708534..d6b14bbf9e 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -38,7 +38,7 @@ type gloasBlockResult struct { func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { // Telemetry labels the route by its primary method: POST when the cluster configured the overlay, // else GET. A per-node GET fallback (a beacon node predating beacon-APIs#630) is still counted under - // POST — an accepted, transitional inaccuracy that disappears as beacon nodes adopt the #630 POST. + // POST — an accepted transitional inaccuracy, tracked with the POST-first flip on issue #2962. httpMethod := http.MethodGet if builderConfig != nil { httpMethod = http.MethodPost @@ -187,9 +187,8 @@ func isMethodOrPathMissing(err error) bool { } // gloasHTTPDo issues an SSZ-accepting request to a Gloas endpoint and returns the response body and headers -// on a 2xx (see httpDo). A non-nil body is sent with the given contentType and tagged with the Gloas -// consensus version; extraHeaders are applied last, except Eth-Consensus-Version, which is forced to the -// Gloas version (no caller overrides it). +// on a 2xx (see httpDo). A non-nil body is sent with the given contentType; extraHeaders are applied last, +// except Eth-Consensus-Version, which is always the Gloas version on requests with a body. func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { if body != nil { merged := make(map[string]string, len(extraHeaders)+1) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 1f524f44a6..181f1ce955 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -120,8 +120,8 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { return nil, errors.New("must have one share") } - // Decode/resolve the builder config once here (once per validator), so the §4 produce path reads - // pre-decoded values instead of re-parsing config every proposal. Startup already validated it. + // Resolve the builder config once per validator — the §4 produce path reads the pre-decoded form. + // Startup already validated it. builders, err := gloas.ResolveBuilderConfig(opts.Builders) if err != nil { return nil, fmt.Errorf("resolve builder config: %w", err) diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 6c2c95b017..4f9685090d 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -39,8 +39,8 @@ type ProposerPreferencesRunner struct { opts ProposerPreferencesRunnerOptions - // builders is opts.Builders decoded/resolved once here (once per validator), handed to every per-slot - // sub-runner so the §5 signing round reads pre-decoded auth data instead of re-parsing config per slot. + // builders is opts.Builders resolved once at construction and handed to every per-slot sub-runner: + // the §5 signing round reads pre-decoded auth data. builders []gloas.ResolvedBuilderEntry // bySlot holds one sub-runner per concurrently-active proposal slot. Accessed only from the @@ -82,7 +82,7 @@ func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner return nil, fmt.Errorf("must have one share") } - // Decode/resolve the builder config once here (once per validator); startup already validated it. + // Resolve the builder config once per validator (see the builders field). Startup already validated it. resolved, err := gloas.ResolveBuilderConfig(opts.Builders) if err != nil { return nil, fmt.Errorf("resolve builder config: %w", err) diff --git a/protocol/v2/ssv/runner/request_auth_test.go b/protocol/v2/ssv/runner/request_auth_test.go index 9e63993589..a680651f8b 100644 --- a/protocol/v2/ssv/runner/request_auth_test.go +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -143,7 +143,7 @@ func TestProposerPreferencesRunner_requestAuthConvergence(t *testing.T) { // Phase 3: each reconstruction also submits the builder preferences to the beacon node. Across the // two reconstructions (builder A via stash replay, then the B/D shared root) every configured builder // is submitted, each carrying its reconstructed auth. - var submittedURLs []string + submittedURLs := make([]string, 0, 3) // the three configured builders asserted below for _, batch := range bn.submittedBuilderPrefs { for _, e := range batch { submittedURLs = append(submittedURLs, e.URL) diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 72775cd847..9a3a7bb99e 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -138,8 +138,8 @@ func (e *BuilderEntry) EffectiveBoostFactor(cfg *BuilderConfig) uint64 { // ResolvedBuilderEntry is a BuilderEntry with its config strings decoded and knobs resolved once, at load // (ResolveBuilderConfig). Identity — BuilderIdentity(URL, AuthData) — is the single key shared by the §5 -// signing round and the §4 auth-cache lookup, so the two match by construction rather than by each -// re-decoding the config identically on every read. +// signing round and the §4 auth-cache lookup, so the two match by construction. The slices are shared, +// never copied (frozen auths alias AuthData, produce bodies alias BuilderPubKeys) — treat them as immutable. type ResolvedBuilderEntry struct { Identity string // BuilderIdentity(URL, AuthData) URL string // the builder URL, verbatim @@ -160,13 +160,13 @@ type ResolvedBuilderConfig struct { } // Configured mirrors BuilderConfig.Configured for the resolved form: any entries or top-level p2p knobs. -func (c ResolvedBuilderConfig) Configured() bool { return c.configured } +func (c *ResolvedBuilderConfig) Configured() bool { return c.configured } // ResolveBuilderConfig validates cfg and decodes it into its runtime form in one pass: it applies every // builder-set check (entry cap, http(s) URLs, decodable within-limit auth data, no duplicate (URL, auth // data) identity, 48-byte builder pubkeys) and, per entry, decodes AuthData, resolves the effective knobs, -// and computes the Identity. Decoding here — once, at load — is what lets the §4/§5 read sites drop their -// per-use decode and its unreachable error branch. ValidateBuilderConfig is this, discarding the result. +// and computes the Identity. Decoding once here is why the §4/§5 read paths carry no parse-error branches. +// ValidateBuilderConfig is this, discarding the result. func ResolveBuilderConfig(cfg BuilderConfig) (ResolvedBuilderConfig, error) { if len(cfg.Entries) > MaxBuilderEntries { return ResolvedBuilderConfig{}, fmt.Errorf("%d builder entries exceed the %d limit", len(cfg.Entries), MaxBuilderEntries) From 3f7387c9d644504b9b72ffa5d5c74a60546c5daf Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 15:07:43 +0300 Subject: [PATCH 144/150] gloas: fix PTC payload_attestation_data URL and 204 abstain handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producePayloadAttestationData slot moved from a path segment to a query parameter in ethereum/beacon-APIs#626 (to match produceAttestationData); Lodestar and Prysm followed. Our hand-rolled GET still used the old path form, so a spec-compliant beacon node 404s it and the PTC duty (SIP-94 §3) never submits. That 404 — not a missing CL endpoint — is what #2963 saw: the duties call keeps its epoch path param and succeeds, only the data call 404s. Reported in #3001. Point the constant at the query form, matching the sibling attestation_data call already in this package. Also handle the endpoint's 204 No Content, the spec's "no block seen for this slot" abstain signal: httpDo now returns the status code so requestPayloadAttestationData yields (nil, nil) on a 204 instead of failing to decode the empty body, and the runner treats nil data as the §3 abstain (markDutyNotRequired) rather than marking the duty failed. --- beacon/goclient/gloas_proposer.go | 3 +- beacon/goclient/ptc.go | 42 +++++++++++++++---------- beacon/goclient/ptc_test.go | 20 ++++++++++-- protocol/v2/blockchain/beacon/client.go | 3 +- protocol/v2/ssv/runner/ptc_attester.go | 8 ++--- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index d6b14bbf9e..84ea0f329e 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -198,7 +198,8 @@ func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentTy merged["Eth-Consensus-Version"] = consensusVersionGloas extraHeaders = merged } - return httpDo(ctx, gloasHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) + respBody, header, _, err := httpDo(ctx, gloasHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) + return respBody, header, err } // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 7b3dbad181..9c361440ef 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -21,8 +21,8 @@ import ( // yet, so these are issued as hand-rolled HTTP requests until it is rebased onto a Gloas-aware // release, at which point they become typed provider calls like the rest of GoClient. const ( - ptcDutiesPath = "/eth/v1/validator/duties/ptc/%d" // epoch - payloadAttestationDataPath = "/eth/v1/validator/payload_attestation_data/%d" // slot + ptcDutiesPath = "/eth/v1/validator/duties/ptc/%d" // epoch + payloadAttestationDataPath = "/eth/v1/validator/payload_attestation_data?slot=%d" // slot payloadAttestationsPath = "/eth/v1/beacon/pool/payload_attestations" // consensusVersionGloas is the Eth-Consensus-Version header value for Gloas payload attestations. @@ -44,7 +44,7 @@ func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.E } // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot, from the -// first beacon client that responds. +// first beacon client that responds, or (nil, nil) if the node reports no block for the slot (204). func (gc *GoClient) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { return firstClientResult(ctx, gc, "PayloadAttestationData", http.MethodGet, func(ctx context.Context, addr string) (*gloas.PayloadAttestationData, error) { return requestPayloadAttestationData(ctx, gloasHTTPClient, addr, slot) @@ -103,13 +103,22 @@ func requestPTCDuties(ctx context.Context, httpClient *http.Client, addr string, return resp.Data, nil } -// requestPayloadAttestationData GETs the PayloadAttestationData the PTC member must attest to for the slot. +// requestPayloadAttestationData GETs the PayloadAttestationData for the slot. A 204 No Content — +// the beacon-APIs "no block seen" signal — returns (nil, nil) rather than a decode error on the +// empty body. func requestPayloadAttestationData(ctx context.Context, httpClient *http.Client, addr string, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { + respBody, _, status, err := httpDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(payloadAttestationDataPath, slot), nil, "application/json", "", nil) + if err != nil { + return nil, err + } + if status == http.StatusNoContent { + return nil, nil + } var resp struct { Data *gloas.PayloadAttestationData `json:"data"` } - if err := jsonDo(ctx, httpClient, http.MethodGet, addr+fmt.Sprintf(payloadAttestationDataPath, slot), nil, nil, &resp); err != nil { - return nil, err + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) } if resp.Data == nil { return nil, errors.New("no payload attestation data in response") @@ -141,17 +150,18 @@ func (e *httpStatusError) Error() string { return fmt.Sprintf("%s %s: status %d: %s", e.method, e.url, e.status, e.body) } -// httpDo issues a hand-rolled Gloas HTTP request and returns the response body and headers on a 2xx, or -// a *httpStatusError otherwise. accept sets the Accept header; a non-nil body is sent with contentType. -// extraHeaders are applied last. It is the shared core of the JSON (jsonDo) and SSZ (gloasHTTPDo) helpers. -func httpDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, accept, contentType string, extraHeaders map[string]string) ([]byte, http.Header, error) { +// httpDo issues a hand-rolled Gloas HTTP request and returns the response body, headers, and status +// code on a 2xx, or a *httpStatusError otherwise. accept sets the Accept header; a non-nil body is +// sent with contentType; extraHeaders are applied last. The status lets a 2xx caller tell a 200 from +// a 204. Shared core of the JSON (jsonDo) and SSZ (gloasHTTPDo) helpers. +func httpDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, accept, contentType string, extraHeaders map[string]string) ([]byte, http.Header, int, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, url, reader) if err != nil { - return nil, nil, fmt.Errorf("new request: %w", err) + return nil, nil, 0, fmt.Errorf("new request: %w", err) } req.Header.Set("Accept", accept) if body != nil && contentType != "" { @@ -163,25 +173,25 @@ func httpDo(ctx context.Context, httpClient *http.Client, method, url string, bo resp, err := httpClient.Do(req) if err != nil { - return nil, nil, fmt.Errorf("%s %s: %w", method, url, err) + return nil, nil, 0, fmt.Errorf("%s %s: %w", method, url, err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("read response body: %w", err) + return nil, nil, resp.StatusCode, fmt.Errorf("read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, nil, &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + return nil, nil, resp.StatusCode, &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} } - return respBody, resp.Header, nil + return respBody, resp.Header, resp.StatusCode, nil } // jsonDo issues a JSON request and, on a 2xx response, decodes the body into out (out may be nil to // ignore the body). A nil body sends no request payload; extraHeaders are applied last. Non-2xx // responses surface as *httpStatusError. func jsonDo(ctx context.Context, httpClient *http.Client, method, url string, body []byte, extraHeaders map[string]string, out any) error { - respBody, _, err := httpDo(ctx, httpClient, method, url, body, "application/json", "application/json", extraHeaders) + respBody, _, _, err := httpDo(ctx, httpClient, method, url, body, "application/json", "application/json", extraHeaders) if err != nil { return err } diff --git a/beacon/goclient/ptc_test.go b/beacon/goclient/ptc_test.go index bc96bc584c..fcaffa1b3a 100644 --- a/beacon/goclient/ptc_test.go +++ b/beacon/goclient/ptc_test.go @@ -46,9 +46,9 @@ func TestRequestPayloadAttestationData(t *testing.T) { dataJSON, err := json.Marshal(data) require.NoError(t, err) - var gotMethod, gotPath string + var gotMethod, gotPath, gotQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod, gotPath = r.Method, r.URL.Path + gotMethod, gotPath, gotQuery = r.Method, r.URL.Path, r.URL.RawQuery _, _ = fmt.Fprintf(w, `{"version":"gloas","data":%s}`, dataJSON) })) defer srv.Close() @@ -56,10 +56,24 @@ func TestRequestPayloadAttestationData(t *testing.T) { got, err := requestPayloadAttestationData(context.Background(), srv.Client(), srv.URL, 9) require.NoError(t, err) require.Equal(t, http.MethodGet, gotMethod) - require.Equal(t, "/eth/v1/validator/payload_attestation_data/9", gotPath) + require.Equal(t, "/eth/v1/validator/payload_attestation_data", gotPath) + require.Equal(t, "slot=9", gotQuery) require.Equal(t, data, got) } +// A 204 No Content is the beacon-APIs "no block seen" signal: requestPayloadAttestationData surfaces +// it as (nil, nil), not an error, so the PTC member abstains. +func TestRequestPayloadAttestationData_NoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + got, err := requestPayloadAttestationData(context.Background(), srv.Client(), srv.URL, 9) + require.NoError(t, err) + require.Nil(t, got) +} + func TestSubmitPayloadAttestationMessages(t *testing.T) { msgs := []*gloas.PayloadAttestationMessage{{ ValidatorIndex: 7, diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index bdafa65011..37f44f6cab 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -83,7 +83,8 @@ type VoluntaryExitCalls interface { type PTCCalls interface { // PayloadAttestationDuties returns the PTC duties for the given validators at the epoch. PayloadAttestationDuties(ctx context.Context, epoch phase0.Epoch, validatorIndices []phase0.ValidatorIndex) ([]*gloas.PTCDuty, error) - // PayloadAttestationData returns the PayloadAttestationData to attest to for the slot. + // PayloadAttestationData returns the data to attest to for the slot, or (nil, nil) if the beacon + // node reports no block seen for the slot (HTTP 204) — the SIP-94 §3 signal to abstain. PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) // SubmitPayloadAttestationMessages submits signed PTC messages to the beacon node's pool. SubmitPayloadAttestationMessages(ctx context.Context, messages []*gloas.PayloadAttestationMessage) error diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index bdb9035d41..098bff2a3e 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -167,10 +167,10 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, r.markDutyFailed(err) return nil } - // BN contract: an all-zero BeaconBlockRoot signals "no block for this slot" — the SIP #94 §3 abstain - // trigger. We sign and submit nothing; markDutyNotRequired still records the abstention for metrics. - // Caveat: a BN erroneously returning a zero root is indistinguishable from a genuine abstention here. - if data.BeaconBlockRoot == (phase0.Root{}) { + // No block seen: nil data is the beacon node's 204 "no block" signal; a zero BeaconBlockRoot is the + // same, kept defensively for a BN that answers 200 with a zero root. markDutyNotRequired records the + // abstention. (A BN wrongly returning nil/zero is indistinguishable from a genuine abstention.) + if data == nil || data.BeaconBlockRoot == (phase0.Root{}) { logger.Debug("abstaining from PTC attestation: no beacon block for slot", fields.Slot(slot)) r.markDutyNotRequired() return nil From 067f5ccbcbb42cff48c71f52f0a58bbeac7849d6 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 15:22:06 +0300 Subject: [PATCH 145/150] gloas: extract the Eth-Consensus-Version header name into a constant The header key was a bare string literal at five goclient call sites (only its value, consensusVersionGloas, was already a constant). Add consensusVersionHeader alongside it and use it everywhere, clearing the goconst lint hit. Tests keep the literal, to pin the wire header independently. --- beacon/goclient/builder_preferences.go | 2 +- beacon/goclient/gloas_proposer.go | 4 ++-- beacon/goclient/proposer_preferences.go | 2 +- beacon/goclient/ptc.go | 8 +++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/beacon/goclient/builder_preferences.go b/beacon/goclient/builder_preferences.go index 9242fae7eb..983dca7ef5 100644 --- a/beacon/goclient/builder_preferences.go +++ b/beacon/goclient/builder_preferences.go @@ -35,7 +35,7 @@ func submitBuilderPreferences(ctx context.Context, httpClient *http.Client, addr if err != nil { return fmt.Errorf("marshal builder preferences: %w", err) } - headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + headers := map[string]string{consensusVersionHeader: consensusVersionGloas} err = jsonDo(ctx, httpClient, http.MethodPost, addr+builderPreferencesPath, body, headers, nil) if isNotFound(err) { return fmt.Errorf("beacon node lacks the gloas builder_preferences endpoint (beacon-APIs#630): %w", err) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index 84ea0f329e..c03cf86b63 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -137,7 +137,7 @@ func requestGloasBeaconBlockPOST(ctx context.Context, url string, builderConfig // produce response's Eth-Consensus-Version is present but not "gloas". An absent header is tolerated (not // every node sets it on the response), with the SSZ decode as the backstop. func checkGloasConsensusVersion(header http.Header) error { - if v := header.Get("Eth-Consensus-Version"); v != "" && !strings.EqualFold(v, consensusVersionGloas) { + if v := header.Get(consensusVersionHeader); v != "" && !strings.EqualFold(v, consensusVersionGloas) { return fmt.Errorf("produce response Eth-Consensus-Version %q, want %q", v, consensusVersionGloas) } return nil @@ -195,7 +195,7 @@ func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentTy for k, v := range extraHeaders { merged[k] = v } - merged["Eth-Consensus-Version"] = consensusVersionGloas + merged[consensusVersionHeader] = consensusVersionGloas extraHeaders = merged } respBody, header, _, err := httpDo(ctx, gloasHTTPClient, method, url, body, "application/octet-stream", contentType, extraHeaders) diff --git a/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go index 9506de75af..6003acf838 100644 --- a/beacon/goclient/proposer_preferences.go +++ b/beacon/goclient/proposer_preferences.go @@ -71,7 +71,7 @@ func submitProposerPreferences(ctx context.Context, httpClient *http.Client, add if err != nil { return fmt.Errorf("marshal proposer preferences: %w", err) } - headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + headers := map[string]string{consensusVersionHeader: consensusVersionGloas} err = jsonDo(ctx, httpClient, http.MethodPost, addr+proposerPreferencesPath, body, headers, nil) if isNotFound(err) { return fmt.Errorf("beacon node lacks the gloas proposer_preferences endpoint (beacon-APIs#608): %w", err) diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index 9c361440ef..d2afa21ab6 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -25,8 +25,10 @@ const ( payloadAttestationDataPath = "/eth/v1/validator/payload_attestation_data?slot=%d" // slot payloadAttestationsPath = "/eth/v1/beacon/pool/payload_attestations" - // consensusVersionGloas is the Eth-Consensus-Version header value for Gloas payload attestations. - consensusVersionGloas = "gloas" + // consensusVersionHeader is the beacon-APIs consensus-version header; consensusVersionGloas is its + // value on Gloas requests. + consensusVersionHeader = "Eth-Consensus-Version" + consensusVersionGloas = "gloas" ) // gloasHTTPClient issues the hand-rolled Gloas requests; per-call deadlines come from the request context. @@ -132,7 +134,7 @@ func submitPayloadAttestationMessages(ctx context.Context, httpClient *http.Clie if err != nil { return fmt.Errorf("marshal payload attestation messages: %w", err) } - headers := map[string]string{"Eth-Consensus-Version": consensusVersionGloas} + headers := map[string]string{consensusVersionHeader: consensusVersionGloas} return jsonDo(ctx, httpClient, http.MethodPost, addr+payloadAttestationsPath, body, headers, nil) } From 501079b6c002cc1ad16168cc88e401298f1ae767 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 17:04:55 +0300 Subject: [PATCH 146/150] gloas: POST-first produceBlockV4 with a neutral local-build config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit produceBlockV4 became POST-only with a required BuilderConfig body in ethereum/beacon-APIs#630 (merged 2026-08-24); Teku already dropped the GET. The node sent a bare GET whenever no direct builder was configured — always, today — so Gloas block production fails against any spec-current beacon node (only Lighthouse/Lodestar/Prysm still keep the GET, hiding it for now). Always POST the BuilderConfig body: the direct-builder overlay when configured, else a neutral local-build config (empty builders, builder_boost_factor 100 — SSV's neutral, so p2p bids compete at par with the local build). The per-node GET fallback stays for pre-#630 nodes, only on a 404/405. Docs and comments describing the old "unconfigured -> GET" behavior are updated to match. Reported in #3002; completes the POST-first flip deferred in this PR. --- beacon/goclient/gloas_proposer.go | 56 +++++++++---------- beacon/goclient/gloas_proposer_test.go | 42 ++++++++++++-- docs/EXTERNAL_BUILDERS.md | 5 +- protocol/v2/blockchain/beacon/client.go | 9 +-- protocol/v2/ssv/runner/proposer.go | 2 +- protocol/v2/types/gloas/builder_entry.go | 4 +- protocol/v2/types/gloas/builder_entry_test.go | 2 +- .../v2/types/gloas/produce_builder_config.go | 7 +++ .../gloas/produce_builder_config_test.go | 13 +++++ 9 files changed, 96 insertions(+), 44 deletions(-) diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index c03cf86b63..b52a2cc190 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -16,9 +16,10 @@ import ( // Gloas produce/publish endpoints. Produce is v4 with include_payload=false: a Gloas block carries only // the execution-payload bid (the payload ships in the §6 envelope), so the response is a bare BeaconBlock — -// no BlockContents. The direct-builder overlay (beacon-APIs#630) sends a BuilderConfig POST body; beacon -// nodes that predate it (beacon-APIs#580, GET-only) are handled by the per-node GET fallback. Publish is -// the standard v2 blocks endpoint (version-tagged via Eth-Consensus-Version). +// no BlockContents. Produce POSTs a BuilderConfig body (beacon-APIs#630) — the direct-builder overlay when +// configured, else a neutral local-build config — and falls back per beacon node to the legacy GET for +// nodes that predate the POST (beacon-APIs#580, GET-only). Publish is the standard v2 blocks endpoint +// (version-tagged via Eth-Consensus-Version). const ( gloasProduceBlockPath = "/eth/v4/validator/blocks/%d?randao_reveal=%s&graffiti=%s&include_payload=false" // slot, randao 0x-hex, graffiti 0x-hex gloasPublishBlockPath = "/eth/v2/beacon/blocks" @@ -32,18 +33,13 @@ type gloasBlockResult struct { } // GetGloasBeaconBlock produces a Gloas (ePBS) block via the v4 produce endpoint, decoding the SSZ response -// (go-eth2-client has no Gloas types). A non-nil builderConfig is POSTed as the produceBlockV4 body -// (beacon-APIs#630, direct-builder overlay), falling back per beacon node to the GET for nodes that predate -// it; the returned string is the winning builder's Eth-Builder-Url, if any. +// (go-eth2-client has no Gloas types). It POSTs a BuilderConfig body (beacon-APIs#630): builderConfig when +// the direct-builder overlay is configured, else a neutral local-build config. It falls back per beacon +// node to the legacy GET for nodes that predate the POST; the returned string is the winning builder's +// Eth-Builder-Url, if any. func (gc *GoClient) GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { - // Telemetry labels the route by its primary method: POST when the cluster configured the overlay, - // else GET. A per-node GET fallback (a beacon node predating beacon-APIs#630) is still counted under - // POST — an accepted transitional inaccuracy, tracked with the POST-first flip on issue #2962. - httpMethod := http.MethodGet - if builderConfig != nil { - httpMethod = http.MethodPost - } - res, err := firstClientResult(ctx, gc, "GetGloasBeaconBlock", httpMethod, func(ctx context.Context, addr string) (gloasBlockResult, error) { + // A per-node GET fallback (a pre-#630 node) is still counted under this POST label — a transitional inaccuracy. + res, err := firstClientResult(ctx, gc, "GetGloasBeaconBlock", http.MethodPost, func(ctx context.Context, addr string) (gloasBlockResult, error) { return requestGloasBeaconBlock(ctx, addr, slot, graffiti, randao, builderConfig) }) return res.block, res.builderURL, err @@ -73,30 +69,30 @@ func (gc *GoClient) SubmitGloasBeaconBlock(ctx context.Context, block *gloas.Sig }) } -// requestGloasBeaconBlock produces one Gloas block from a single beacon node. With a builderConfig it POSTs -// the beacon-APIs#630 body and, only on a 404/405 (the node predates the POST), retries as the GET carrying -// builder_boost_factor (the sole knob the pre-#630 GET also honors). +// requestGloasBeaconBlock produces one Gloas block from a single beacon node. It POSTs the beacon-APIs#630 +// BuilderConfig body — a neutral local-build config when builderConfig is nil — and, only on a 404/405 (the +// node predates the POST), retries as the legacy GET carrying builder_boost_factor (the sole knob the +// pre-#630 GET also honors). func requestGloasBeaconBlock(ctx context.Context, addr string, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (gloasBlockResult, error) { + if builderConfig == nil { + builderConfig = gloas.NeutralProduceBuilderConfig() + } // Graffiti must be a full 32-byte value in the query — lighthouse rejects a short one with 400 // "Invalid query string" (mirror the mature GetBeaconBlock path which pads to [32]byte). g := [32]byte{} copy(g[:], graffiti) url := addr + fmt.Sprintf(gloasProduceBlockPath, slot, "0x"+hex.EncodeToString(randao), "0x"+hex.EncodeToString(g[:])) - if builderConfig != nil { - res, err := requestGloasBeaconBlockPOST(ctx, url, builderConfig) - if err == nil { - return res, nil - } - if !isMethodOrPathMissing(err) { - return gloasBlockResult{}, err - } - // The node predates the produceBlockV4 POST; fall back to the GET, still carrying - // builder_boost_factor — the one knob the pre-#630 GET (beacon-APIs#580) also honors, same - // semantics (builder bids weighed against the local payload at 100). min_bid and the per-builder - // inputs have no GET counterpart, so they are POST-only. - url += fmt.Sprintf("&builder_boost_factor=%d", builderConfig.BuilderBoostFactor) + res, err := requestGloasBeaconBlockPOST(ctx, url, builderConfig) + if err == nil { + return res, nil + } + if !isMethodOrPathMissing(err) { + return gloasBlockResult{}, err } + // Fall back to the legacy GET. It honors only builder_boost_factor — min_bid and the per-builder inputs + // are POST-only — with the same semantics: bids weighed against the local payload at 100. + url += fmt.Sprintf("&builder_boost_factor=%d", builderConfig.BuilderBoostFactor) respBody, header, err := gloasHTTPDo(ctx, http.MethodGet, url, nil, "", nil) if err != nil { diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go index 7d26de4fbb..c3855e58df 100644 --- a/beacon/goclient/gloas_proposer_test.go +++ b/beacon/goclient/gloas_proposer_test.go @@ -19,32 +19,66 @@ import ( // GoClient must satisfy the Gloas proposer beacon-node surface. var _ beacon.GloasProposerCalls = (*GoClient)(nil) +// With no builder config, produce still POSTs (produceBlockV4 is POST-first per beacon-APIs#630), carrying +// a neutral local-build body: empty builders with the neutral boost factor (100). func TestRequestGloasBeaconBlock(t *testing.T) { blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() require.NoError(t, err) - var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotIncludePayload, gotBoost string + var gotMethod, gotPath, gotRandao, gotGraffiti, gotAccept, gotContentType, gotIncludePayload string + var gotBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path gotRandao = r.URL.Query().Get("randao_reveal") gotGraffiti = r.URL.Query().Get("graffiti") gotIncludePayload = r.URL.Query().Get("include_payload") - gotBoost = r.URL.Query().Get("builder_boost_factor") gotAccept = r.Header.Get("Accept") + gotContentType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) _, _ = w.Write(blockSSZ) })) defer srv.Close() got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, nil) require.NoError(t, err) - require.Equal(t, http.MethodGet, gotMethod) + require.Equal(t, http.MethodPost, gotMethod) require.Equal(t, "/eth/v4/validator/blocks/7", gotPath) require.Equal(t, "false", gotIncludePayload) // bare block; payload ships in the §6 envelope require.Equal(t, "0x01", gotRandao) // randao is the 5th arg, graffiti the 4th // graffiti is padded to a full 32-byte value before hex-encoding (lighthouse rejects a short one). require.Equal(t, "0x02"+strings.Repeat("00", 31), gotGraffiti) - require.Empty(t, gotBoost, "no knobs (builder_boost_factor) on an unconfigured cluster's GET") require.Equal(t, "application/octet-stream", gotAccept) + require.Equal(t, "application/json", gotContentType) + // the neutral local-build body: no builders, p2p bids weighed at par with the local build (100). + require.Contains(t, string(gotBody), `"builders":[]`) + require.Contains(t, string(gotBody), `"builder_boost_factor":"100"`) + require.Equal(t, phase0.Slot(7), got.block.Slot) +} + +// The common transitional path: an unconfigured cluster against a beacon node that still serves only the +// GET (Lighthouse/Lodestar/Prysm today). The neutral POST is rejected (405) and the fallback GET carries +// the neutral boost factor (100). +func TestRequestGloasBeaconBlock_UnconfiguredFallbackToGET(t *testing.T) { + blockSSZ, err := gloas.TestingBeaconBlock(7).MarshalSSZ() + require.NoError(t, err) + + var methods []string + var getBoost string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) // node predates beacon-APIs#630 (GET-only) + return + } + getBoost = r.URL.Query().Get("builder_boost_factor") + _, _ = w.Write(blockSSZ) + })) + defer srv.Close() + + got, err := requestGloasBeaconBlock(context.Background(), srv.URL, 7, []byte{0x02}, []byte{0x01}, nil) + require.NoError(t, err) + require.Equal(t, []string{http.MethodPost, http.MethodGet}, methods, "unconfigured POST 405 falls back to GET") + require.Equal(t, "100", getBoost, "the fallback GET carries the neutral builder_boost_factor") require.Equal(t, phase0.Slot(7), got.block.Slot) } diff --git a/docs/EXTERNAL_BUILDERS.md b/docs/EXTERNAL_BUILDERS.md index 512bea69d4..37ce1f7661 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -62,8 +62,9 @@ back to the enshrined flow (gossiped bids / self-build) on any failure: `submitBuilderPreferences` (the `MaxExecutionPayment` cap) through its own beacon node, so the builder holds it before the bid request arrives. -A cluster with no `Builders` config keeps the plain enshrined GET produce. A beacon node that predates the -#630 POST answers it with 404/405; the node falls back to the legacy GET for that node, still carrying +A cluster with no `Builders` config produces over the same `produceBlockV4` POST, sending a neutral +local-build config (empty `builders`, `builder_boost_factor` 100). A beacon node that predates the #630 +POST answers it with 404/405; the node falls back to the legacy GET for that node, still carrying `BuilderBoostFactor` (the one knob that GET honors) — `MinBid` and the per-entry knobs are POST-only. ## How to use diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 37f44f6cab..f16e563b86 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -110,10 +110,11 @@ type ProposerPreferencesCalls interface { // POST (beacon-APIs#630). type GloasProposerCalls interface { // GetGloasBeaconBlock produces a Gloas beacon block for the slot; the payload itself ships - // separately in the §6 envelope, so the block carries only the execution-payload bid. A non-nil - // builderConfig is sent as the produceBlockV4 POST body (beacon-APIs#630, direct-builder overlay), - // with a GET fallback for beacon nodes that predate it; the returned string is the Eth-Builder-Url - // of the winning builder-API bid, empty when self-built or won by a p2p bid. + // separately in the §6 envelope, so the block carries only the execution-payload bid. It is sent as + // the produceBlockV4 POST body (beacon-APIs#630) — builderConfig when the direct-builder overlay is + // configured, else a neutral local-build config — with a GET fallback for beacon nodes that predate + // it; the returned string is the Eth-Builder-Url of the winning builder-API bid, empty when + // self-built or won by a p2p bid. GetGloasBeaconBlock(ctx context.Context, slot phase0.Slot, graffiti, randao []byte, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) // SubmitGloasBeaconBlock publishes a signed Gloas block. A non-empty builderURL is echoed as the // Eth-Builder-Url header so the beacon node forwards the block to the winning builder (beacon-APIs#630). diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 181f1ce955..d207f7517b 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -74,7 +74,7 @@ type ProposerRunner struct { // builders is the cluster's direct-builder config, resolved once at construction (issue #2962, phase 2): // the produceBlockV4 POST body is assembled from it plus the per-slot reconstructed auths. Not - // Configured() -> the enshrined GET. + // Configured() -> a neutral local-build POST body. builders gloas.ResolvedBuilderConfig // requestAuthCache holds the per-slot reconstructed builder auths this operator attaches to the // produceBlockV4 POST. Shared with the §5 dispatcher that writes it; nil pre-Gloas / no overlay. diff --git a/protocol/v2/types/gloas/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go index 9a3a7bb99e..659409d970 100644 --- a/protocol/v2/types/gloas/builder_entry.go +++ b/protocol/v2/types/gloas/builder_entry.go @@ -95,8 +95,8 @@ func (c *BuilderConfig) EffectiveBoostFactor() uint64 { } // Configured reports whether the operator set any direct-builder configuration — entries or the top-level -// p2p knobs (MinBid / BuilderBoostFactor); the zero value is false. §4 POSTs produceBlockV4 when true and -// uses the enshrined GET when false (imposing no proposer knobs on an unconfigured cluster) — so a +// p2p knobs (MinBid / BuilderBoostFactor); the zero value is false. Either way §4 produces over the +// produceBlockV4 POST — this resolved config when true, a neutral local-build config when false — so a // knobs-only config is honored, and clearing entries for a remote signer keeps the p2p knobs. func (c *BuilderConfig) Configured() bool { return len(c.Entries) > 0 || c.MinBid != 0 || c.BuilderBoostFactor != nil diff --git a/protocol/v2/types/gloas/builder_entry_test.go b/protocol/v2/types/gloas/builder_entry_test.go index e6370e0b38..852b2375fd 100644 --- a/protocol/v2/types/gloas/builder_entry_test.go +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -48,7 +48,7 @@ func TestBuilderEntry_Effective(t *testing.T) { } func TestBuilderConfig_Configured(t *testing.T) { - require.False(t, (&BuilderConfig{}).Configured(), "zero value is not configured -> §4 uses the enshrined GET") + require.False(t, (&BuilderConfig{}).Configured(), "zero value is not configured -> §4 produces with a neutral local-build config") require.True(t, (&BuilderConfig{Entries: []BuilderEntry{{URL: "https://x.example"}}}).Configured(), "entries -> configured") require.True(t, (&BuilderConfig{MinBid: 1}).Configured(), "top-level MinBid -> configured (knobs-only)") zero := uint64(0) diff --git a/protocol/v2/types/gloas/produce_builder_config.go b/protocol/v2/types/gloas/produce_builder_config.go index 314ad46cd1..959d8b8329 100644 --- a/protocol/v2/types/gloas/produce_builder_config.go +++ b/protocol/v2/types/gloas/produce_builder_config.go @@ -100,3 +100,10 @@ func BuildProduceConfig(cfg ResolvedBuilderConfig, auths map[string]*SignedBuild } return out, authUnavailable } + +// NeutralProduceBuilderConfig is the produceBlockV4 POST body a cluster with no builders configured sends: +// an empty builders list with the neutral boost factor, so the beacon node weighs any p2p bid at par with +// the local build (beacon-APIs#630). +func NeutralProduceBuilderConfig() *ProduceBuilderConfig { + return &ProduceBuilderConfig{BuilderBoostFactor: defaultBuilderBoostFactor} +} diff --git a/protocol/v2/types/gloas/produce_builder_config_test.go b/protocol/v2/types/gloas/produce_builder_config_test.go index f0f2dc1c1a..e84a469808 100644 --- a/protocol/v2/types/gloas/produce_builder_config_test.go +++ b/protocol/v2/types/gloas/produce_builder_config_test.go @@ -66,3 +66,16 @@ func TestProduceBuilderConfig_MarshalJSON(t *testing.T) { require.Contains(t, s, `"builder_pubkeys":["0xab00`) // pubkey as 0x-hex require.Contains(t, s, `"auth":{"message":`) // nested SignedBuilderRequestAuth object } + +func TestNeutralProduceBuilderConfig(t *testing.T) { + // The no-builders local-build body: neutral boost (100), no min-bid floor, and an empty (not null) + // builders list — the beacon-APIs#630 shape a cluster with no builders configured POSTs. + c := NeutralProduceBuilderConfig() + require.Equal(t, uint64(defaultBuilderBoostFactor), c.BuilderBoostFactor) + require.Zero(t, c.MinBid) + require.Empty(t, c.Builders) + + b, err := json.Marshal(c) + require.NoError(t, err) + require.JSONEq(t, `{"min_bid":"0","builder_boost_factor":"100","builders":[]}`, string(b)) +} From fd68806a19cf7239831c79bbbf4c9f913c1aa049 Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 19:29:13 +0300 Subject: [PATCH 147/150] gloas: PTC abstain test + POST-first/204 doc-comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the PTC (#3001) and produceBlockV4 (#3002) fixes: - Add a runner unit test for the PTC abstain path: executeDuty calls markDutyNotRequired and freezes no observation when the beacon node reports no block — nil data (a 204) or, defensively, a 200 with an all-zero root. - Fix a stale doc comment on gloasBuilderConfig: a nil config no longer means "the enshrined GET"; the goclient now POSTs a neutral local-build config. - Document that a 204 stops the client fallback (an authoritative "no block", so the operator abstains on its own node's view rather than polling the rest) — a deliberate multi-BN choice, not an accident. --- beacon/goclient/ptc.go | 6 ++-- protocol/v2/ssv/runner/proposer.go | 8 +++--- protocol/v2/ssv/runner/ptc_attester_test.go | 32 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go index d2afa21ab6..25544ed308 100644 --- a/beacon/goclient/ptc.go +++ b/beacon/goclient/ptc.go @@ -45,8 +45,10 @@ func (gc *GoClient) PayloadAttestationDuties(ctx context.Context, epoch phase0.E }) } -// PayloadAttestationData returns the PayloadAttestationData to attest to for the slot, from the -// first beacon client that responds, or (nil, nil) if the node reports no block for the slot (204). +// PayloadAttestationData returns the PayloadAttestationData to attest to for the slot, from the first +// beacon client that responds, or (nil, nil) if that node reports no block for the slot (204). A 204 is +// an answer, not an error, so it stops the client fallback — the operator abstains on its own node's +// view rather than polling the rest for a block. func (gc *GoClient) PayloadAttestationData(ctx context.Context, slot phase0.Slot) (*gloas.PayloadAttestationData, error) { return firstClientResult(ctx, gc, "PayloadAttestationData", http.MethodGet, func(ctx context.Context, addr string) (*gloas.PayloadAttestationData, error) { return requestPayloadAttestationData(ctx, gloasHTTPClient, addr, slot) diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index d207f7517b..6a1ab360ce 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -337,10 +337,10 @@ func (r *ProposerRunner) gloasProposalInput(ctx context.Context, logger *zap.Log } // gloasBuilderConfig assembles the produceBlockV4 POST body from the cluster's direct-builder config and -// the per-slot reconstructed auths (beacon-APIs#630), or nil when nothing is configured (→ the enshrined -// GET). Builders whose auth missed quorum this slot are omitted and counted for the E1 auth-unavailable -// signal; the top-level p2p knobs are always carried. The goclient falls back to GET per beacon node that -// predates #630. +// the per-slot reconstructed auths (beacon-APIs#630), or nil when nothing is configured (the goclient then +// POSTs a neutral local-build config). Builders whose auth missed quorum this slot are omitted and counted +// for the E1 auth-unavailable signal; the top-level p2p knobs are always carried. The goclient falls back +// to GET per beacon node that predates #630. func (r *ProposerRunner) gloasBuilderConfig(ctx context.Context, slot phase0.Slot) *gloas.ProduceBuilderConfig { if !r.builders.Configured() { return nil diff --git a/protocol/v2/ssv/runner/ptc_attester_test.go b/protocol/v2/ssv/runner/ptc_attester_test.go index dd68c359e9..77d61c5b12 100644 --- a/protocol/v2/ssv/runner/ptc_attester_test.go +++ b/protocol/v2/ssv/runner/ptc_attester_test.go @@ -8,8 +8,10 @@ import ( ssz "github.com/ferranbt/fastssz" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "go.uber.org/zap" + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) @@ -49,3 +51,33 @@ func TestPTCAttesterRunner_NoConsensusPhases(t *testing.T) { require.Error(t, r.ProcessConsensus(context.Background(), zap.NewNop(), nil)) require.Error(t, r.ProcessPostConsensus(context.Background(), zap.NewNop(), nil)) } + +// executeDuty abstains (markDutyNotRequired, no observation frozen, no signing) when the beacon node +// reports no block for the slot — surfaced either as nil data (a 204 No Content) or, defensively, a +// 200 with an all-zero BeaconBlockRoot. +func TestPTCAttesterRunner_ExecuteDutyAbstains(t *testing.T) { + for _, tc := range []struct { + name string + data *gloas.PayloadAttestationData + }{ + {"nil data (204 no block)", nil}, + {"zero beacon block root", &gloas.PayloadAttestationData{}}, + } { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + bn := beacon.NewMockBeaconNode(ctrl) + bn.EXPECT().PayloadAttestationData(gomock.Any(), phase0.Slot(9)).Return(tc.data, nil) + + r := &PTCAttesterRunner{ + BaseRunner: &BaseRunner{RunnerRoleType: spectypes.RolePTCAttester}, + beacon: bn, + } + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRolePTCAttester, Slot: 9} + r.State = NewRunnerState(1, duty) + + require.NoError(t, r.executeDuty(context.Background(), zap.NewNop(), duty)) + require.True(t, r.State.Succeeded, "abstains via markDutyNotRequired") + require.Nil(t, r.payloadAttestationData, "abstaining freezes no observation") + }) + } +} From 65d060684f5f918339d9080dff1b31096cdbc82f Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 20:58:01 +0300 Subject: [PATCH 148/150] gloas: mark BlindedExecutionPayloadEnvelope internal-only; drop dead signed-blinded code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beacon-APIs#624 removed the BlindedExecutionPayloadEnvelope container from the spec. SSV keeps a type of that name purely as the §6 QBFT consensus value (the full SignedExecutionPayloadEnvelope is what's published), so document that and drop the now-dead code for the removed blinded publication path: - Note on BlindedExecutionPayloadEnvelope: an SSV-internal consensus value only, never on the wire (#624 removed the identically named spec container). - Remove SignedBlindedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelope .Blinded(), their generated SSZ, the round-trip test, and the sszgen obj. - Correct the stale "blinded publication path" comments to say #624 removed the blinded body; the only deferred alternative is the full-envelope Contents. Confirms #3003 (BlindedExecutionPayloadEnvelope stays an SSV-internal container); SIP-94 §6 already documents this ("No public SignedBlindedExecutionPayloadEnvelope is used"), so no SIP change is needed. --- beacon/goclient/gloas_envelope.go | 11 +-- .../types/gloas/execution_payload_envelope.go | 33 ++----- .../execution_payload_envelope_encoding.go | 99 ------------------- .../gloas/execution_payload_envelope_test.go | 37 ------- 4 files changed, 13 insertions(+), 167 deletions(-) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index 34f6c8d536..a9498c070a 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -30,12 +30,11 @@ func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0 // nodes concurrently, succeeding if at least one accepts it. Re-publishing to multiple BNs is safe — they // dedupe by block root. // -// The body is the full SignedExecutionPayloadEnvelope. beacon-APIs#580 also defines a blinded body -// (Eth-Execution-Payload-Blinded, reconstructed from the BN's cache) and an unblinded -// SignedExecutionPayloadEnvelopeContents (envelope + blobs + KZG proofs), but Lodestar v1.43.0 — the first -// CL to implement the endpoint — decodes only the full SignedExecutionPayloadEnvelope. The full envelope's -// hash-tree root equals the blinded root the §6 QBFT signed, so the reconstructed signature stays valid. -// The blinded form is retained in the gloas types for the deferred blinded/Contents path. +// The body is the full SignedExecutionPayloadEnvelope, whose hash-tree root equals the blinded root the §6 +// QBFT signed, so the reconstructed signature stays valid. beacon-APIs#580 also defined a blinded body, but +// beacon-APIs#624 removed it; the only remaining alternative is the stateless SignedExecutionPayloadEnvelope +// Contents (envelope + blobs + KZG proofs), not yet wired. Lodestar v1.43.0 — the first CL to implement the +// endpoint — decodes only the full SignedExecutionPayloadEnvelope. func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { body, err := signed.MarshalSSZ() if err != nil { diff --git a/protocol/v2/types/gloas/execution_payload_envelope.go b/protocol/v2/types/gloas/execution_payload_envelope.go index aa81e8b968..402769395e 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope.go +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -7,9 +7,9 @@ import ( ) // Regenerate with `go generate ./...`. -path is the package dir (not just this file) so sszgen resolves -// the sibling gloas BuilderIndex the envelope references; --objs limits output to the blinded envelope, +// the sibling gloas BuilderIndex the envelope references; --objs limits output to the envelope types, // collected into its own _encoding.go. Includes track go-eth2-client via `go list -m`. -//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,SignedBlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload,ExecutionRequests,BuilderDepositRequest,BuilderExitRequest --output ./execution_payload_envelope_encoding.go" +//go:generate sh -c "go tool -modfile=../../../../tool.mod sszgen -path . --include $(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/phase0,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/electra,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/bellatrix,$(go list -m -f '{{.Dir}}' github.com/attestantio/go-eth2-client)/spec/capella --objs BlindedExecutionPayloadEnvelope,ExecutionPayloadEnvelope,SignedExecutionPayloadEnvelope --exclude-objs ExecutionPayload,ExecutionRequests,BuilderDepositRequest,BuilderExitRequest --output ./execution_payload_envelope_encoding.go" // BlindedExecutionPayloadEnvelope is the blinded form of the Gloas ExecutionPayloadEnvelope that the §6 // envelope-signing duty signs (SIP #94 §6): the full `payload` is replaced by @@ -17,6 +17,9 @@ import ( // equals the full envelope's, so a BLS signature over the blinded signing root is valid for the full // SignedExecutionPayloadEnvelope. It rides in EnvelopeConsensusData.DataSSZ; blinding keeps that QBFT // value bounded — a few hundred bytes rather than the full payload's hundreds of KB to ~MB. +// +// It is an SSV-internal consensus type only, never sent on the wire: beacon-APIs#624 removed the +// identically named spec container, and §6 publishes the full SignedExecutionPayloadEnvelope. type BlindedExecutionPayloadEnvelope struct { PayloadRoot phase0.Root `ssz-size:"32"` // Gloas execution requests — the EIP-8282 five-list variant, not electra's three (see execution_requests.go). @@ -43,23 +46,14 @@ type ExecutionPayloadEnvelope struct { } // SignedExecutionPayloadEnvelope wraps the envelope with the builder's signature (under -// DOMAIN_BEACON_BUILDER). The cluster reconstructs this full signed form; it is published as either the -// blinded body below (stateful: the producing BN un-blinds from cache) or, for stateless cross-BN -// failover, a SignedExecutionPayloadEnvelopeContents (full envelope + blobs/KZG — not yet wired). +// DOMAIN_BEACON_BUILDER). The cluster reconstructs this full signed form and publishes it as-is (§6); +// beacon-APIs#624 removed the blinded publication body, so the only deferred alternative is the stateless +// SignedExecutionPayloadEnvelopeContents (full envelope + blobs/KZG — not yet wired). type SignedExecutionPayloadEnvelope struct { Message *ExecutionPayloadEnvelope Signature phase0.BLSSignature `ssz-size:"96"` } -// SignedBlindedExecutionPayloadEnvelope wraps the blinded envelope with the builder's signature — the §6 -// publication body on the blinded (stateful) path, where the producing beacon node reconstructs the full -// envelope from its cache. The signature is valid here because the blinded root equals the full envelope's -// (see BlindedExecutionPayloadEnvelope) — the same property the §6 duty relies on to sign the blinded form. -type SignedBlindedExecutionPayloadEnvelope struct { - Message *BlindedExecutionPayloadEnvelope - Signature phase0.BLSSignature `ssz-size:"96"` -} - // Blinded returns the blinded form of the envelope — the full Payload replaced by its hash-tree root. // The blinded envelope hashes to the same root as this one, so the §6 duty agrees on and signs the // blinded value while the signature stays valid for this full envelope. The non-Payload fields are @@ -77,14 +71,3 @@ func (e *ExecutionPayloadEnvelope) Blinded() (*BlindedExecutionPayloadEnvelope, ParentBeaconBlockRoot: e.ParentBeaconBlockRoot, }, nil } - -// Blinded returns the signed blinded form for §6 publication: the same signature carried onto the blinded -// envelope (valid for both, since their roots match). Shares the inner envelope's non-Payload fields, so -// the result must not outlive this one. -func (s *SignedExecutionPayloadEnvelope) Blinded() (*SignedBlindedExecutionPayloadEnvelope, error) { - blinded, err := s.Message.Blinded() - if err != nil { - return nil, err - } - return &SignedBlindedExecutionPayloadEnvelope{Message: blinded, Signature: s.Signature}, nil -} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go index 82d2357643..ebbbe480bd 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_encoding.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -387,102 +387,3 @@ func (s *SignedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (er func (s *SignedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { return ssz.ProofTree(s) } - -// MarshalSSZ ssz marshals the SignedBlindedExecutionPayloadEnvelope object -func (s *SignedBlindedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { - return ssz.MarshalSSZ(s) -} - -// MarshalSSZTo ssz marshals the SignedBlindedExecutionPayloadEnvelope object to a target array -func (s *SignedBlindedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { - dst = buf - offset := int(100) - - // Offset (0) 'Message' - dst = ssz.WriteOffset(dst, offset) - - // Field (1) 'Signature' - dst = append(dst, s.Signature[:]...) - - // Field (0) 'Message' - if dst, err = s.Message.MarshalSSZTo(dst); err != nil { - return - } - - return -} - -// UnmarshalSSZ ssz unmarshals the SignedBlindedExecutionPayloadEnvelope object -func (s *SignedBlindedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) error { - var err error - size := uint64(len(buf)) - if size < 100 { - return ssz.ErrSize - } - - tail := buf - var o0 uint64 - - // Offset (0) 'Message' - if o0 = ssz.ReadOffset(buf[0:4]); o0 > size { - return ssz.ErrOffset - } - - if o0 != 100 { - return ssz.ErrInvalidVariableOffset - } - - // Field (1) 'Signature' - copy(s.Signature[:], buf[4:100]) - - // Field (0) 'Message' - { - buf = tail[o0:] - if s.Message == nil { - s.Message = new(BlindedExecutionPayloadEnvelope) - } - if err = s.Message.UnmarshalSSZ(buf); err != nil { - return err - } - } - return err -} - -// SizeSSZ returns the ssz encoded size in bytes for the SignedBlindedExecutionPayloadEnvelope object -func (s *SignedBlindedExecutionPayloadEnvelope) SizeSSZ() (size int) { - size = 100 - - // Field (0) 'Message' - if s.Message == nil { - s.Message = new(BlindedExecutionPayloadEnvelope) - } - size += s.Message.SizeSSZ() - - return -} - -// HashTreeRoot ssz hashes the SignedBlindedExecutionPayloadEnvelope object -func (s *SignedBlindedExecutionPayloadEnvelope) HashTreeRoot() ([32]byte, error) { - return ssz.HashWithDefaultHasher(s) -} - -// HashTreeRootWith ssz hashes the SignedBlindedExecutionPayloadEnvelope object with a hasher -func (s *SignedBlindedExecutionPayloadEnvelope) HashTreeRootWith(hh ssz.HashWalker) (err error) { - indx := hh.Index() - - // Field (0) 'Message' - if err = s.Message.HashTreeRootWith(hh); err != nil { - return - } - - // Field (1) 'Signature' - hh.PutBytes(s.Signature[:]) - - hh.Merkleize(indx) - return -} - -// GetTree ssz hashes the SignedBlindedExecutionPayloadEnvelope object -func (s *SignedBlindedExecutionPayloadEnvelope) GetTree() (*ssz.Node, error) { - return ssz.ProofTree(s) -} diff --git a/protocol/v2/types/gloas/execution_payload_envelope_test.go b/protocol/v2/types/gloas/execution_payload_envelope_test.go index 75ba34b2cc..03b58150dd 100644 --- a/protocol/v2/types/gloas/execution_payload_envelope_test.go +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -116,40 +116,3 @@ func TestExecutionPayloadEnvelopeBlindsToSameRoot(t *testing.T) { require.NoError(t, err) require.Equal(t, blindedRoot, fullRoot, "blinded envelope must hash to the same root as the full envelope") } - -// SignedExecutionPayloadEnvelope.Blinded carries the signature unchanged onto the blinded envelope (whose -// message root equals the full one's), and the resulting signed blinded envelope — the §6 publication body -// on the blinded path — round-trips through SSZ. -func TestSignedExecutionPayloadEnvelopeBlindedRoundTrip(t *testing.T) { - full := &SignedExecutionPayloadEnvelope{ - Message: &ExecutionPayloadEnvelope{ - Payload: sampleExecutionPayload(), - ExecutionRequests: &ExecutionRequests{}, - BuilderIndex: BuilderIndexSelfBuild, - BeaconBlockRoot: phase0.Root{0x02}, - ParentBeaconBlockRoot: phase0.Root{0x03}, - }, - Signature: phase0.BLSSignature{0xab, 0xcd}, - } - - signedBlinded, err := full.Blinded() - require.NoError(t, err) - require.Equal(t, full.Signature, signedBlinded.Signature, "signature must be carried unchanged") - - // The blinded message hashes to the same root as the full envelope, so the carried signature is valid. - fullMsgRoot, err := full.Message.HashTreeRoot() - require.NoError(t, err) - blindedMsgRoot, err := signedBlinded.Message.HashTreeRoot() - require.NoError(t, err) - require.Equal(t, fullMsgRoot, blindedMsgRoot) - - b, err := signedBlinded.MarshalSSZ() - require.NoError(t, err) - out := &SignedBlindedExecutionPayloadEnvelope{} - require.NoError(t, out.UnmarshalSSZ(b)) - r1, err := signedBlinded.HashTreeRoot() - require.NoError(t, err) - r2, err := out.HashTreeRoot() - require.NoError(t, err) - require.Equal(t, r1, r2) -} From 0ee72aacd55591ed4023451df44c81bcdee383af Mon Sep 17 00:00:00 2001 From: iurii Date: Thu, 27 Aug 2026 21:06:40 +0300 Subject: [PATCH 149/150] =?UTF-8?q?gloas:=20set=20the=20required=20Eth-Blo?= =?UTF-8?q?b-Data-Included=20header=20on=20=C2=A76=20envelope=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beacon-APIs#624 makes Eth-Blob-Data-Included a required header on POST /eth/v1/beacon/execution_payload_envelopes: it selects the request body — false for the full SignedExecutionPayloadEnvelope (stateful flow, the beacon node attaches the blobs it cached at production), true for the blobs-carrying Contents — and the SSZ octet-stream body is ambiguous without it. The node published the full envelope with no such header, so a spec-compliant beacon node rejects it (the same class as the produceBlockV4 gap in #3002); it only worked because Lodestar v1.43.0 implements just the full form. Set Eth-Blob-Data-Included: false on the envelope publish, and correct two comments that still named the old #580 Eth-Execution-Payload-Blinded header (the pre-#624 §6 name). The pre-Gloas produceBlockV3 use of that header is unaffected. --- beacon/goclient/gloas_envelope.go | 22 +++++++++++----------- beacon/goclient/gloas_envelope_test.go | 13 +++++++------ beacon/goclient/gloas_proposer.go | 4 ++-- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/beacon/goclient/gloas_envelope.go b/beacon/goclient/gloas_envelope.go index a9498c070a..ec17db9e24 100644 --- a/beacon/goclient/gloas_envelope.go +++ b/beacon/goclient/gloas_envelope.go @@ -32,9 +32,10 @@ func (gc *GoClient) GetExecutionPayloadEnvelope(ctx context.Context, slot phase0 // // The body is the full SignedExecutionPayloadEnvelope, whose hash-tree root equals the blinded root the §6 // QBFT signed, so the reconstructed signature stays valid. beacon-APIs#580 also defined a blinded body, but -// beacon-APIs#624 removed it; the only remaining alternative is the stateless SignedExecutionPayloadEnvelope -// Contents (envelope + blobs + KZG proofs), not yet wired. Lodestar v1.43.0 — the first CL to implement the -// endpoint — decodes only the full SignedExecutionPayloadEnvelope. +// beacon-APIs#624 removed it; the required Eth-Blob-Data-Included header now selects the full envelope +// (false, the stateful flow — the beacon node attaches the blobs it cached at production) over the deferred, +// stateless SignedExecutionPayloadEnvelopeContents (true — envelope + blobs + KZG, not yet wired). Lodestar +// v1.43.0, the first CL to implement the endpoint, takes the full envelope. func (gc *GoClient) SubmitExecutionPayloadEnvelope(ctx context.Context, signed *gloas.SignedExecutionPayloadEnvelope) error { body, err := signed.MarshalSSZ() if err != nil { @@ -63,15 +64,14 @@ func requestExecutionPayloadEnvelope(ctx context.Context, addr string, slot phas return envelope, nil } -// submitExecutionPayloadEnvelope POSTs the SSZ-marshaled full signed envelope to the publish endpoint. No -// Eth-Execution-Payload-Blinded header: Lodestar decodes the body as a full SignedExecutionPayloadEnvelope -// (gloasOctetStreamHTTP tags the request with the Gloas Eth-Consensus-Version). -// -// An already-known response is treated as success: on the self-build path every operator publishes the -// identical envelope, so the non-winning ones race the canonical one and get EXECUTION_PAYLOAD_ENVELOPE_ -// ERROR_ALREADY_KNOWN — the §6 analog of the §4 block submit (see submitGloasBeaconBlock). +// submitExecutionPayloadEnvelope POSTs the SSZ full signed envelope, tagged Eth-Blob-Data-Included: false +// per beacon-APIs#624 (see SubmitExecutionPayloadEnvelope). An already-known response is treated as success: +// on the self-build path every operator publishes the identical envelope, so the non-winning ones race the +// canonical one and get EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN — the §6 analog of the §4 block +// submit (see submitGloasBeaconBlock). func submitExecutionPayloadEnvelope(ctx context.Context, addr string, envelopeSSZ []byte) error { - _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ, nil) + headers := map[string]string{"Eth-Blob-Data-Included": "false"} + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ, headers) if isAlreadyKnown(err) { return nil } diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go index 20081c5bbb..4baba006e1 100644 --- a/beacon/goclient/gloas_envelope_test.go +++ b/beacon/goclient/gloas_envelope_test.go @@ -50,13 +50,13 @@ func TestRequestExecutionPayloadEnvelope(t *testing.T) { } func TestSubmitExecutionPayloadEnvelope(t *testing.T) { - var gotMethod, gotPath, gotVersion, gotContentType, gotBlinded string + var gotMethod, gotPath, gotVersion, gotContentType, gotBlobDataIncluded string var gotBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path gotVersion = r.Header.Get("Eth-Consensus-Version") gotContentType = r.Header.Get("Content-Type") - gotBlinded = r.Header.Get("Eth-Execution-Payload-Blinded") + gotBlobDataIncluded = r.Header.Get("Eth-Blob-Data-Included") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) @@ -67,7 +67,8 @@ func TestSubmitExecutionPayloadEnvelope(t *testing.T) { require.Equal(t, http.MethodPost, gotMethod) require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes", gotPath) require.Equal(t, consensusVersionGloas, gotVersion) - require.Empty(t, gotBlinded) // full envelope, not blinded — no Eth-Execution-Payload-Blinded header + // full envelope (stateful flow), not the blobs-carrying Contents — the required beacon-APIs#624 header. + require.Equal(t, "false", gotBlobDataIncluded) require.Equal(t, "application/octet-stream", gotContentType) require.Equal(t, []byte{0x01, 0x02}, gotBody) } @@ -82,10 +83,10 @@ func TestSubmitExecutionPayloadEnvelope_PublishesFullSignedEnvelope(t *testing.T wantBody, err := signed.MarshalSSZ() require.NoError(t, err) - var gotBlinded string + var gotBlobDataIncluded string var gotBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotBlinded = r.Header.Get("Eth-Execution-Payload-Blinded") + gotBlobDataIncluded = r.Header.Get("Eth-Blob-Data-Included") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) @@ -100,7 +101,7 @@ func TestSubmitExecutionPayloadEnvelope_PublishesFullSignedEnvelope(t *testing.T } require.NoError(t, gc.SubmitExecutionPayloadEnvelope(t.Context(), signed)) - require.Empty(t, gotBlinded, "publish must not blind the envelope") + require.Equal(t, "false", gotBlobDataIncluded, "publish selects the full envelope, not the blobs-carrying Contents") require.Equal(t, wantBody, gotBody, "publish must send the full signed envelope SSZ") } diff --git a/beacon/goclient/gloas_proposer.go b/beacon/goclient/gloas_proposer.go index b52a2cc190..f76be1a7e7 100644 --- a/beacon/goclient/gloas_proposer.go +++ b/beacon/goclient/gloas_proposer.go @@ -200,8 +200,8 @@ func gloasHTTPDo(ctx context.Context, method, url string, body []byte, contentTy // gloasOctetStreamHTTP issues an octet-stream (SSZ) request to a Gloas produce/publish endpoint and returns // the response body on a 2xx. A nil body GETs; a non-nil body POSTs SSZ tagged with the Gloas consensus -// version. extraHeaders (e.g. Eth-Builder-Url on publish, Eth-Execution-Payload-Blinded for the §6 -// envelope) are applied last. +// version. extraHeaders (e.g. Eth-Builder-Url on the §4 block publish, Eth-Blob-Data-Included on the §6 +// envelope publish) are applied last. func gloasOctetStreamHTTP(ctx context.Context, method, url string, body []byte, extraHeaders map[string]string) ([]byte, error) { respBody, _, err := gloasHTTPDo(ctx, method, url, body, "application/octet-stream", extraHeaders) return respBody, err From 28c257f557d24bb9dbdd2e53effd5cb5b73d6c91 Mon Sep 17 00:00:00 2001 From: iurii Date: Fri, 28 Aug 2026 12:51:04 +0300 Subject: [PATCH 150/150] gloas: repin ssv-spec to epbs-gloas-types head; typed MsgID constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old ssv-spec pin pointed at an orphaned commit; move both go.mods (root and ssvsigner) to the current epbs-gloas-types head. That head splits the generic spectypes.NewMsgID(domain, []byte, role) into the fixed-size spectypes.NewValidatorMsgID / spectypes.NewCommitteeMsgID. Migrate all production call sites to the typed constructors — output is byte-identical, so message ids stay wire-compatible: validator-keyed runners use NewValidatorMsgID, committee-keyed ones NewCommitteeMsgID, and the generic []byte identifier resolver picks by executor length. Tests build synthetic and intentionally-malformed ids from arbitrary-length executor bytes, which the fixed-size constructors cannot express, so add protocol/v2/types/ssvtestingutils.NewMsgID — a test-only reproduction of the removed constructor — and repoint the test call sites at it. Also: - type the []byte validator-pubkey params of the signAndBroadcast* and dutyDataToSSVMsg helpers as spectypes.ValidatorPK, dropping [:] at callers; - build the identifier directly per switch case in the testing runner helper; - go mod tidy: prune stale ssv-spec go.sum entries and mark golang.org/x/exp (imported directly by the ssv spectest helpers) as a direct dependency. --- .../dutytracer/collector_aggregator_test.go | 7 +- exporter/dutytracer/collector_quorum_test.go | 13 +-- exporter/dutytracer/collector_test.go | 25 +++--- exporter/traces/model_encoding_test.go | 10 ++- go.mod | 4 +- go.sum | 10 +-- .../validation/consensus_validation_test.go | 5 +- message/validation/envelope_proposer_test.go | 3 +- message/validation/logger_fields_test.go | 3 +- .../validation/proposer_preferences_test.go | 3 +- message/validation/ptc_attester_test.go | 3 +- message/validation/signed_ssv_message_test.go | 5 +- message/validation/validation_test.go | 85 ++++++++++--------- network/p2p/p2p_test.go | 7 +- network/topics/controller_test.go | 3 +- network/topics/msg_validator_test.go | 3 +- operator/validator/controller.go | 10 ++- operator/validator/controller_bench_test.go | 3 +- operator/validator/controller_test.go | 11 +-- operator/validator/identifier_fn_test.go | 5 +- operator/validator/router_test.go | 9 +- .../qbft/controller/controller_fork_test.go | 19 +++-- .../v2/queue/worker/message_worker_test.go | 7 +- .../v2/ssv/queue/message_prioritizer_test.go | 5 +- protocol/v2/ssv/runner/aggregator.go | 4 +- .../v2/ssv/runner/aggregator_committee.go | 8 +- ...gator_postconsensus_classification_test.go | 3 +- protocol/v2/ssv/runner/committee.go | 4 +- protocol/v2/ssv/runner/envelope.go | 2 +- protocol/v2/ssv/runner/envelope_e2e_test.go | 5 +- .../v2/ssv/runner/preconsensus_domain_test.go | 8 +- protocol/v2/ssv/runner/proposer.go | 4 +- .../v2/ssv/runner/proposer_preferences.go | 2 +- .../proposer_preferences_request_auth.go | 2 +- protocol/v2/ssv/runner/proposer_test.go | 5 +- protocol/v2/ssv/runner/ptc_attester.go | 2 +- protocol/v2/ssv/runner/runner.go | 8 +- .../ssv/runner/sync_committee_contribution.go | 4 +- ...tee_contribution_preconsensus_flow_test.go | 3 +- .../v2/ssv/runner/validator_registration.go | 2 +- protocol/v2/ssv/runner/voluntary_exit.go | 2 +- protocol/v2/ssv/testing/runner.go | 13 +-- protocol/v2/ssv/validator/committee.go | 2 +- .../ssv/validator/committee_observer_test.go | 3 +- protocol/v2/ssv/validator/duty_executor.go | 6 +- protocol/v2/ssv/validator/startup.go | 2 +- protocol/v2/ssv/validator/validator.go | 2 +- .../v2/types/ssvtestingutils/message_id.go | 30 +++++++ ssvsigner/go.mod | 2 +- ssvsigner/go.sum | 8 +- 50 files changed, 222 insertions(+), 172 deletions(-) create mode 100644 protocol/v2/types/ssvtestingutils/message_id.go diff --git a/exporter/dutytracer/collector_aggregator_test.go b/exporter/dutytracer/collector_aggregator_test.go index 43b1f145c7..98cd38d9a6 100644 --- a/exporter/dutytracer/collector_aggregator_test.go +++ b/exporter/dutytracer/collector_aggregator_test.go @@ -16,6 +16,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/registry/storage" registrystoragemocks "github.com/ssvlabs/ssv/registry/storage/mocks" ) @@ -192,7 +193,7 @@ func TestCollector_AggregatorCommitteeDuty_PostConsensusQuorum(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("agg_committee_pk"), spectypes.RoleAggregatorCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("agg_committee_pk"), spectypes.RoleAggregatorCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -321,7 +322,7 @@ func TestCollector_AggregatorCommitteeDuty_PreConsensusQuorum(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("agg_committee_pk_pre"), spectypes.RoleAggregatorCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("agg_committee_pk_pre"), spectypes.RoleAggregatorCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -411,7 +412,7 @@ func TestCollector_AggregatorCommitteeDuty_UnknownRootBuffersUntilProposal(t *te t.Cleanup(ctrl.Finish) const slot = phase0.Slot(2) - identifier := spectypes.NewMsgID([4]byte{}, []byte("agg_committee_pk_2"), spectypes.RoleAggregatorCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("agg_committee_pk_2"), spectypes.RoleAggregatorCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) diff --git a/exporter/dutytracer/collector_quorum_test.go b/exporter/dutytracer/collector_quorum_test.go index 3bbbf4d351..31491f96e0 100644 --- a/exporter/dutytracer/collector_quorum_test.go +++ b/exporter/dutytracer/collector_quorum_test.go @@ -15,6 +15,7 @@ import ( "github.com/ssvlabs/ssv/exporter/traces" "github.com/ssvlabs/ssv/networkconfig" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/registry/storage" registrystoragemocks "github.com/ssvlabs/ssv/registry/storage/mocks" ) @@ -35,7 +36,7 @@ func TestCollector_QuorumAfterFlush(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -130,7 +131,7 @@ func TestCollector_RoleSpecificQuorum(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -233,7 +234,7 @@ func TestCollector_MixedTimingQuorum(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -342,7 +343,7 @@ func TestCollector_UnknownRootQuorum(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -442,7 +443,7 @@ func TestCollector_MultipleValidatorsAndRoles(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -539,7 +540,7 @@ func TestCollector_checkAndPublishQuorumForRoleByIndex(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) diff --git a/exporter/dutytracer/collector_test.go b/exporter/dutytracer/collector_test.go index 0a27450509..0b5671e922 100644 --- a/exporter/dutytracer/collector_test.go +++ b/exporter/dutytracer/collector_test.go @@ -26,6 +26,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/registry/storage" registrystoragemocks "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" @@ -47,7 +48,7 @@ func TestValidatorDuty(t *testing.T) { vIndex = phase0.ValidatorIndex(55) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("pk"), role) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), role) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -382,7 +383,7 @@ func TestValidatorDuties(t *testing.T) { vIndex = phase0.ValidatorIndex(55) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("pk"), role) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), role) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -453,7 +454,7 @@ func TestCommitteeDuty(t *testing.T) { vIndex = phase0.ValidatorIndex(55) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -1112,7 +1113,7 @@ func TestCollector_processPartialSigCommittee_UnknownRootBuffers(t *testing.T) { tracer := New(logger, validators, nil, dutyStore, networkconfig.TestNetwork.Beacon, nil, nil) const slot = phase0.Slot(12) - identifier := spectypes.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -1181,7 +1182,7 @@ func TestCollector_FlushPending_Timestamps(t *testing.T) { tracer := New(logger, validators, mockclient{}, dutyStore, networkconfig.TestNetwork.Beacon, nil, nil) const slot = phase0.Slot(13) - identifier := spectypes.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) @@ -1405,7 +1406,7 @@ func TestCollector_newPartialSigVerifyCtx_EmptyMessages(t *testing.T) { collector := &Collector{logger: zap.NewNop()} msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ - MsgID: spectypes.NewMsgID([4]byte{}, []byte("pk"), ssvtypes.RoleAggregator), + MsgID: ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), ssvtypes.RoleAggregator), }, } pSigMessages := &spectypes.PartialSignatureMessages{ @@ -1449,7 +1450,7 @@ func TestCollector_Collect_WrapVerifyPartialSigErrForValidator(t *testing.T) { validators.EXPECT().ValidatorByIndex(missingIndex).Return(nil, false) collector := New(logger, validators, nil, new(mockDutyTraceStore), networkconfig.TestNetwork.Beacon, nil, nil) - msgID := spectypes.NewMsgID([4]byte{}, []byte("pk"), ssvtypes.RoleAggregator) + msgID := ssvtestingutils.NewMsgID([4]byte{}, []byte("pk"), ssvtypes.RoleAggregator) pSigMessages := &spectypes.PartialSignatureMessages{ Type: spectypes.PostConsensusPartialSig, Slot: slot, @@ -1514,7 +1515,7 @@ func TestCollector_Collect_WrapVerifyPartialSigErrForCommittee(t *testing.T) { validators.EXPECT().ValidatorByIndex(missingIndex).Return(nil, false) collector := New(logger, validators, nil, new(mockDutyTraceStore), networkconfig.TestNetwork.Beacon, nil, nil) - msgID := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], msgID.GetDutyExecutorID()[16:]) @@ -1566,7 +1567,7 @@ func TestCollector_lateMessage(t *testing.T) { logger := zap.New(core) collector := New(logger, vstore, nil, dutyStore, networkconfig.TestNetwork.Beacon, nil, nil) - msgID := spectypes.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ @@ -1605,7 +1606,7 @@ func TestCollector_lateMessage(t *testing.T) { logger := zap.New(core) collector := New(logger, vstore, nil, dutyStore, networkconfig.TestNetwork.Beacon, nil, nil) - msgID := spectypes.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ @@ -1659,7 +1660,7 @@ func TestCollector_lateMessage(t *testing.T) { // its retry loop until ctx is canceled (or the retries exhaust ~3s later) — giving // a late-collect goroutine that is reliably still running. func buildInFlightLateMsg(c *Collector) *queue.SSVMessage { - msgID := spectypes.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{1}, []byte{1}, spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], msgID.GetDutyExecutorID()[16:]) c.inFlightCommittee.Set(committeeTraceKey{id: committeeID, role: spectypes.RoleCommittee}, struct{}{}) @@ -1819,7 +1820,7 @@ func TestCollector_PublishDecidedsToListener(t *testing.T) { operator4 = spectypes.OperatorID(4) ) - identifier := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) var committeeID spectypes.CommitteeID copy(committeeID[:], identifier.GetDutyExecutorID()[16:]) diff --git a/exporter/traces/model_encoding_test.go b/exporter/traces/model_encoding_test.go index e0af1530ba..d958f7810e 100644 --- a/exporter/traces/model_encoding_test.go +++ b/exporter/traces/model_encoding_test.go @@ -9,6 +9,8 @@ import ( specqbft "github.com/ssvlabs/ssv-spec/qbft" spectypes "github.com/ssvlabs/ssv-spec/types" + + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestValidatorDutyTrace_MarshallSSZ(t *testing.T) { @@ -236,14 +238,14 @@ func TestDiskMsg_MarshallSSZ(t *testing.T) { OperatorIDs: []spectypes.OperatorID{1, 2, 3}, SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), Data: []byte{1, 2, 3}, }, FullData: []byte{1, 2, 3}, }, Spec: spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), Data: []byte{1, 2, 3}, }, Qbft: specqbft.Message{ @@ -498,14 +500,14 @@ func TestDiskMsg_UnmarshalSSZ_Errors(t *testing.T) { OperatorIDs: []spectypes.OperatorID{1, 2, 3}, SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), Data: []byte{1, 2, 3}, }, FullData: []byte{1, 2, 3}, }, Spec: spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(spectypes.GenesisMainnet, []byte{1, 2, 3}, spectypes.RoleProposer), Data: []byte{1, 2, 3}, }, Qbft: specqbft.Message{ diff --git a/go.mod b/go.mod index 3ea1831e7f..b22e22b060 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.8.1 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab + github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4 github.com/ssvlabs/ssv/ssvsigner v0.0.0-20260415125841-05316f77d5e5 github.com/status-im/keycard-go v0.2.0 github.com/stretchr/testify v1.11.1 @@ -52,6 +52,7 @@ require ( go.uber.org/mock v0.5.2 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20250911091902-df9299821621 golang.org/x/mod v0.29.0 golang.org/x/sync v0.18.0 golang.org/x/text v0.31.0 @@ -97,7 +98,6 @@ require ( go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/go.sum b/go.sum index 2463b8ff9a..38a8b2e8e6 100644 --- a/go.sum +++ b/go.sum @@ -731,14 +731,8 @@ github.com/ssvlabs/eth2-key-manager v1.5.6 h1:BMxVCsbcIlUiiO0hpePkHxzX0yhKgMkEzV github.com/ssvlabs/eth2-key-manager v1.5.6/go.mod h1:tjzhmMzrc0Lzc/OMW1h9Mz8AhmKH7FQC/nFiMNJ0bd8= github.com/ssvlabs/go-eth2-client v0.6.31-0.20250922150906-26179dd60c9c h1:iNQoRbEajriawtkSFiyHsJNiXyfyTrPrnmO0NaWiNv4= github.com/ssvlabs/go-eth2-client v0.6.31-0.20250922150906-26179dd60c9c/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12 h1:yGQ4e0VZa3TTntgd58nArJy6rllrodwkew8hZ1uAeOY= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260305184636-289c93aa4c12/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab h1:qwxLRgbxrFP47FlIc2zqczIW/q/B5wIOZvYNS1rpgH4= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4 h1:kH8KBv49TuA1PA+ZaQv1ljMewP/trobX/R5U2xS1AQA= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/message/validation/consensus_validation_test.go b/message/validation/consensus_validation_test.go index 81365ef3b2..d376671245 100644 --- a/message/validation/consensus_validation_test.go +++ b/message/validation/consensus_validation_test.go @@ -11,6 +11,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/qbft/roundtimer" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestMessageValidator_currentEstimatedRound(t *testing.T) { @@ -153,7 +154,7 @@ func TestMessageValidator_roundBelongsToAllowedSpread(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) signedSSVMessage := &spectypes.SignedSSVMessage{ SSVMessage: &spectypes.SSVMessage{ - MsgID: spectypes.NewMsgID(netCfg.DomainType, make([]byte, 48), spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(netCfg.DomainType, make([]byte, 48), spectypes.RoleProposer), }, } @@ -220,7 +221,7 @@ func TestMessageValidator_roundBelongsToAllowedSpread(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - signedSSVMessage.SSVMessage.MsgID = spectypes.NewMsgID(netCfg.DomainType, make([]byte, 48), tc.role) + signedSSVMessage.SSVMessage.MsgID = ssvtestingutils.NewMsgID(netCfg.DomainType, make([]byte, 48), tc.role) err := mv.roundBelongsToAllowedSpread( signedSSVMessage, &specqbft.Message{ diff --git a/message/validation/envelope_proposer_test.go b/message/validation/envelope_proposer_test.go index 9b98dbacfa..ba99e56a8b 100644 --- a/message/validation/envelope_proposer_test.go +++ b/message/validation/envelope_proposer_test.go @@ -12,6 +12,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) // The §6 envelope duty is QBFT with only a post-consensus partial signature (no pre-consensus phase). @@ -46,7 +47,7 @@ func TestMaxRound_EnvelopeProposer(t *testing.T) { // At most one self-build proposal (hence one envelope) per slot → at most SlotsPerEpoch per epoch. func TestDutyLimit_EnvelopeProposer(t *testing.T) { mv := &messageValidator{netCfg: networkconfig.TestNetwork} - msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeProposer) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeProposer) limit, ok := mv.dutyLimit(msgID, 0, nil) require.True(t, ok) diff --git a/message/validation/logger_fields_test.go b/message/validation/logger_fields_test.go index b4f7fa9ceb..d4541a43e6 100644 --- a/message/validation/logger_fields_test.go +++ b/message/validation/logger_fields_test.go @@ -13,6 +13,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" registrystorage "github.com/ssvlabs/ssv/registry/storage" ) @@ -418,7 +419,7 @@ func TestBuildLoggerFields_DutyID(t *testing.T) { } ssvMsg := &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.DomainType{}, dutyExecutorID, role), + MsgID: ssvtestingutils.NewMsgID(spectypes.DomainType{}, dutyExecutorID, role), } return &queue.SSVMessage{ SignedSSVMessage: &spectypes.SignedSSVMessage{SSVMessage: ssvMsg}, diff --git a/message/validation/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go index 0cbbe6f465..ca707005e8 100644 --- a/message/validation/proposer_preferences_test.go +++ b/message/validation/proposer_preferences_test.go @@ -15,6 +15,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestPartialSignatureTypeMatchesRole_ProposerPreferences(t *testing.T) { @@ -132,7 +133,7 @@ func TestValidRoleAtSlot_ValidatorRegistrationDeprecatedAtGloas(t *testing.T) { func TestDutyLimit_ProposerPreferences(t *testing.T) { mv := &messageValidator{netCfg: networkconfig.TestNetwork} - msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleProposerPreferences) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleProposerPreferences) limit, ok := mv.dutyLimit(msgID, 0, nil) require.True(t, ok) diff --git a/message/validation/ptc_attester_test.go b/message/validation/ptc_attester_test.go index 5cd0ce9fb6..c79cd27e29 100644 --- a/message/validation/ptc_attester_test.go +++ b/message/validation/ptc_attester_test.go @@ -11,13 +11,14 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/operator/duties/dutystore" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) // A PTC member is drawn from a beacon committee, and a validator sits on exactly one beacon committee // per epoch → at most one PTC duty per epoch, plus a reorg margin → limit 2. func TestDutyLimit_PTCAttester(t *testing.T) { mv := &messageValidator{netCfg: networkconfig.TestNetwork} - msgID := spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RolePTCAttester) + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RolePTCAttester) limit, ok := mv.dutyLimit(msgID, 0, nil) require.True(t, ok) diff --git a/message/validation/signed_ssv_message_test.go b/message/validation/signed_ssv_message_test.go index a630a40b65..8f302d1af3 100644 --- a/message/validation/signed_ssv_message_test.go +++ b/message/validation/signed_ssv_message_test.go @@ -9,6 +9,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) // The three Gloas runner roles must pass the fork-independent validRoleUnion gate in @@ -24,7 +25,7 @@ func TestValidateSSVMessage_GloasRolesPassRoleUnion(t *testing.T) { } { msg := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), role), + MsgID: ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), role), Data: []byte{1}, } require.NoError(t, mv.validateSSVMessage(msg), "role %d must pass the role union", role) @@ -33,7 +34,7 @@ func TestValidateSSVMessage_GloasRolesPassRoleUnion(t *testing.T) { // Negative control: an out-of-union role still REJECTs at the same gate. bad := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RunnerRole(999)), + MsgID: ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RunnerRole(999)), Data: []byte{1}, } require.ErrorIs(t, mv.validateSSVMessage(bad), ErrInvalidRole) diff --git a/message/validation/validation_test.go b/message/validation/validation_test.go index a880df4e9e..f4c706c8c7 100644 --- a/message/validation/validation_test.go +++ b/message/validation/validation_test.go @@ -42,6 +42,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/qbft" "github.com/ssvlabs/ssv/protocol/v2/qbft/roundtimer" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" registrystorage "github.com/ssvlabs/ssv/registry/storage" "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" @@ -159,8 +160,8 @@ func Test_ValidateSSVMessage(t *testing.T) { nonCommitteeRole := ssvtypes.RoleAggregator encodedCommitteeID := append(bytes.Repeat([]byte{0}, 16), committeeID[:]...) - committeeIdentifier := spectypes.NewMsgID(netCfg.DomainType, encodedCommitteeID, committeeRole) - nonCommitteeIdentifier := spectypes.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), nonCommitteeRole) + committeeIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, encodedCommitteeID, committeeRole) + nonCommitteeIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), nonCommitteeRole) peerID, err := libp2ptest.RandPeerID() require.NoError(t, err) @@ -175,7 +176,7 @@ func Test_ValidateSSVMessage(t *testing.T) { validator := New(postBooleCfg, validatorStore, operators, dutyStore, signatureVerifier).(*messageValidator) slot := postBooleCfg.FirstSlotAtEpoch(1) - booleIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) + booleIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, booleIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -191,7 +192,7 @@ func Test_ValidateSSVMessage(t *testing.T) { validator := New(postBooleCfg, validatorStore, operators, dutyStore, signatureVerifier).(*messageValidator) slot := postBooleCfg.FirstSlotAtEpoch(1) - booleIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) + booleIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) signedSSVMessage := generateSignedMessage(ks, booleIdentifier, slot) // Alan topic on a post-fork slot must be rejected. @@ -205,7 +206,7 @@ func Test_ValidateSSVMessage(t *testing.T) { validator := New(postBooleCfg, validatorStore, operators, dutyStore, signatureVerifier).(*messageValidator) slot := postBooleCfg.FirstSlotAtEpoch(1) - booleIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) + booleIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, committeeRole) signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, booleIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -243,7 +244,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := postBooleCfg.FirstSlotAtEpoch(1) // Alan (current) domain on a post-fork slot: passes the pre-decode allowlist but must be // rejected by the slot-exact domain check. - alanDomainIdentifier := spectypes.NewMsgID(postBooleCfg.DomainType, encodedCommitteeID, committeeRole) + alanDomainIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainType, encodedCommitteeID, committeeRole) signedSSVMessage := generateSignedMessage(ks, alanDomainIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -261,7 +262,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) // pre-fork (default TestNetwork Boole=MaxUint64) // Boole (next) domain on a pre-fork slot: passes the pre-decode allowlist but must be // rejected by the slot-exact domain check. - booleDomainIdentifier := spectypes.NewMsgID(netCfg.NextDomainType, encodedCommitteeID, committeeRole) + booleDomainIdentifier := ssvtestingutils.NewMsgID(netCfg.NextDomainType, encodedCommitteeID, committeeRole) signedSSVMessage := generateSignedMessage(ks, booleDomainIdentifier, slot) alanTopic := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -633,7 +634,7 @@ func Test_ValidateSSVMessage(t *testing.T) { sk, err := eth2types.GenerateBLSPrivateKey() require.NoError(t, err) - unknown := spectypes.NewMsgID(netCfg.DomainType, sk.PublicKey().Marshal(), nonCommitteeRole) + unknown := ssvtestingutils.NewMsgID(netCfg.DomainType, sk.PublicKey().Marshal(), nonCommitteeRole) signedSSVMessage := generateSignedMessage(ks, unknown, slot) _, exists := validatorStore.Validator(signedSSVMessage.SSVMessage.GetID().GetDutyExecutorID()) @@ -653,7 +654,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) unknownCommitteeID := bytes.Repeat([]byte{1}, 48) - unknownIdentifier := spectypes.NewMsgID(netCfg.DomainType, unknownCommitteeID, committeeRole) + unknownIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, unknownCommitteeID, committeeRole) signedSSVMessage := generateSignedMessage(ks, unknownIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(spectypes.CommitteeID(signedSSVMessage.SSVMessage.GetID().GetDutyExecutorID()[16:]))[0]) @@ -670,7 +671,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) wrongDomain := spectypes.DomainType{math.MaxUint8, math.MaxUint8, math.MaxUint8, math.MaxUint8} - badIdentifier := spectypes.NewMsgID(wrongDomain, encodedCommitteeID, committeeRole) + badIdentifier := ssvtestingutils.NewMsgID(wrongDomain, encodedCommitteeID, committeeRole) signedSSVMessage := generateSignedMessage(ks, badIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(spectypes.CommitteeID(signedSSVMessage.SSVMessage.GetID().GetDutyExecutorID()[16:]))[0]) @@ -693,7 +694,7 @@ func Test_ValidateSSVMessage(t *testing.T) { // validRoleUnion check in validateSSVMessage (the per-slot validRoleAtSlot narrows valid // roles by fork later). A real validator pubkey is used as the executor ID here; the unknown // -executor-ID variant is covered by the regression subtest below. - badIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], math.MaxInt32) + badIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], math.MaxInt32) signedSSVMessage := generateSignedMessage(ks, badIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -711,7 +712,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) unknownExecutorID := bytes.Repeat([]byte{0xAB}, 48) - badIdentifier := spectypes.NewMsgID(netCfg.DomainType, unknownExecutorID, math.MaxInt32) + badIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, unknownExecutorID, math.MaxInt32) signedSSVMessage := generateSignedMessage(ks, badIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -726,7 +727,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - badIdentifier := spectypes.NewMsgID(netCfg.DomainType, encodedCommitteeID, spectypes.RoleAggregatorCommittee) + badIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, encodedCommitteeID, spectypes.RoleAggregatorCommittee) signedSSVMessage := generateSignedMessage(ks, badIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -741,7 +742,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := postBooleCfg.FirstSlotAtEpoch(1) - badIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], ssvtypes.RoleAggregator) + badIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], ssvtypes.RoleAggregator) // Leader-signed so the subtest keeps asserting the role rejection even if the // validation order between the role and leader checks ever changes. signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, badIdentifier, slot) @@ -761,7 +762,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := postBooleCfg.FirstSlotAtEpoch(1) - badIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], ssvtypes.RoleSyncCommitteeContribution) + badIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], ssvtypes.RoleSyncCommitteeContribution) // Leader-signed for the same reason as the aggregator post-fork subtest above. signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, badIdentifier, slot) @@ -791,7 +792,7 @@ func Test_ValidateSSVMessage(t *testing.T) { }) validator := New(postBooleCfg, validatorStore, operators, ds, signatureVerifier).(*messageValidator) - proposerIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], spectypes.RoleProposer) + proposerIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], spectypes.RoleProposer) signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, proposerIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -807,7 +808,7 @@ func Test_ValidateSSVMessage(t *testing.T) { validator := New(postBooleCfg, validatorStore, operators, dutyStore, signatureVerifier).(*messageValidator) slot := postBooleCfg.FirstSlotAtEpoch(1) - proposerIdentifier := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], spectypes.RoleProposer) + proposerIdentifier := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), shares.active.ValidatorPubKey[:], spectypes.RoleProposer) signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, proposerIdentifier, slot) alanTopic := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -828,7 +829,7 @@ func Test_ValidateSSVMessage(t *testing.T) { }) validator := New(netCfg, validatorStore, operators, ds, signatureVerifier).(*messageValidator) - proposerIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleProposer) + proposerIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleProposer) signedSSVMessage := generateSignedMessage(ks, proposerIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -844,7 +845,7 @@ func Test_ValidateSSVMessage(t *testing.T) { validator := New(netCfg, validatorStore, operators, dutyStore, signatureVerifier).(*messageValidator) slot := netCfg.FirstSlotAtEpoch(1) - proposerIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleProposer) + proposerIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleProposer) signedSSVMessage := generateSignedMessage(ks, proposerIdentifier, slot) committeeInfo, err := validator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -862,7 +863,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - badIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleValidatorRegistration) + badIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleValidatorRegistration) signedSSVMessage := generateSignedMessage(ks, badIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -872,7 +873,7 @@ func Test_ValidateSSVMessage(t *testing.T) { expectedErr.got = spectypes.RoleValidatorRegistration require.ErrorIs(t, err, expectedErr) - badIdentifier = spectypes.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleVoluntaryExit) + badIdentifier = ssvtestingutils.NewMsgID(netCfg.DomainType, shares.active.ValidatorPubKey[:], spectypes.RoleVoluntaryExit) signedSSVMessage = generateSignedMessage(ks, badIdentifier, slot) _, err = validator.handleSignedSSVMessage(context.Background(), signedSSVMessage, topicID, peerID, receivedAt) @@ -886,7 +887,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - liquidatedIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.liquidated.ValidatorPubKey[:], nonCommitteeRole) + liquidatedIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.liquidated.ValidatorPubKey[:], nonCommitteeRole) signedSSVMessage := generateSignedMessage(ks, liquidatedIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -902,7 +903,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - inactiveIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.inactive.ValidatorPubKey[:], nonCommitteeRole) + inactiveIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.inactive.ValidatorPubKey[:], nonCommitteeRole) signedSSVMessage := generateSignedMessage(ks, inactiveIdentifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -918,7 +919,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - nonUpdatedMetadataFutureEpochIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.nonUpdatedMetadataFutureEpoch.ValidatorPubKey[:], nonCommitteeRole) + nonUpdatedMetadataFutureEpochIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.nonUpdatedMetadataFutureEpoch.ValidatorPubKey[:], nonCommitteeRole) signedSSVMessage := generateSignedMessage(ks, nonUpdatedMetadataFutureEpochIdentifier, slot) receivedAt := netCfg.SlotStartTime(slot) @@ -936,7 +937,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.EstimatedCurrentSlot() - nonUpdatedMetadataIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.nonUpdatedMetadata.ValidatorPubKey[:], nonCommitteeRole) + nonUpdatedMetadataIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.nonUpdatedMetadata.ValidatorPubKey[:], nonCommitteeRole) qbftMessage := &specqbft.Message{ MsgType: specqbft.ProposalMsgType, Height: specqbft.Height(slot), @@ -964,7 +965,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - noMetadataIdentifier := spectypes.NewMsgID(netCfg.DomainType, shares.noMetadata.ValidatorPubKey[:], nonCommitteeRole) + noMetadataIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, shares.noMetadata.ValidatorPubKey[:], nonCommitteeRole) signedSSVMessage := generateSignedMessage(ks, noMetadataIdentifier, slot) receivedAt := netCfg.SlotStartTime(slot) @@ -987,7 +988,7 @@ func Test_ValidateSSVMessage(t *testing.T) { }) role := ssvtypes.RoleAggregator - identifier := spectypes.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), role) + identifier := ssvtestingutils.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), role) signedSSVMessage := generateSignedMessage(ks, identifier, slot) // First duty. @@ -1017,7 +1018,7 @@ func Test_ValidateSSVMessage(t *testing.T) { }) validator := New(netCfg, validatorStore, operators, ds, signatureVerifier).(*messageValidator) - identifier := spectypes.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), spectypes.RoleProposer) + identifier := ssvtestingutils.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), spectypes.RoleProposer) signedSSVMessage := generateSignedMessage(ks, identifier, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -1055,7 +1056,7 @@ func Test_ValidateSSVMessage(t *testing.T) { dutyExecutorID := shares.active.ValidatorPubKey[:] ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(netCfgEpoch1.DomainType, dutyExecutorID, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(netCfgEpoch1.DomainType, dutyExecutorID, spectypes.RoleProposer), Data: encodedMessages, } @@ -1083,7 +1084,7 @@ func Test_ValidateSSVMessage(t *testing.T) { dutyExecutorID := shares.active.ValidatorPubKey[:] ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(netCfgEpoch1.DomainType, dutyExecutorID, spectypes.RoleProposer), + MsgID: ssvtestingutils.NewMsgID(netCfgEpoch1.DomainType, dutyExecutorID, spectypes.RoleProposer), Data: encodedMessages, } @@ -1244,7 +1245,7 @@ func Test_ValidateSSVMessage(t *testing.T) { } ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(netCfg.DomainType, dutyExecutorID, role), + MsgID: ssvtestingutils.NewMsgID(netCfg.DomainType, dutyExecutorID, role), Data: encodedMessages, } @@ -1322,7 +1323,7 @@ func Test_ValidateSSVMessage(t *testing.T) { } ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(netCfg.DomainType, dutyExecutorID, role), + MsgID: ssvtestingutils.NewMsgID(netCfg.DomainType, dutyExecutorID, role), Data: encodedMessages, } @@ -1376,7 +1377,7 @@ func Test_ValidateSSVMessage(t *testing.T) { } ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(netCfg.DomainType, dutyExecutorID, role), + MsgID: ssvtestingutils.NewMsgID(netCfg.DomainType, dutyExecutorID, role), Data: encodedMessages, } @@ -1423,7 +1424,7 @@ func Test_ValidateSSVMessage(t *testing.T) { ssvMessage := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleAggregatorCommittee), + MsgID: ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleAggregatorCommittee), Data: encodedMessages, } @@ -1732,7 +1733,7 @@ func Test_ValidateSSVMessage(t *testing.T) { dutyExecutorID = encodedCommitteeID } - msgID := spectypes.NewMsgID(netCfg.DomainType, dutyExecutorID, role) + msgID := ssvtestingutils.NewMsgID(netCfg.DomainType, dutyExecutorID, role) signedSSVMessage := generateSignedMessage(ks, msgID, slot) topicID := commons.GetTopicFullName(commons.CommitteeTopicID(committeeID)[0]) @@ -1745,7 +1746,7 @@ func Test_ValidateSSVMessage(t *testing.T) { postBooleValidator := New(postBooleCfg, validatorStore, operators, ds, signatureVerifier).(*messageValidator) acSlot := postBooleCfg.FirstSlotAtEpoch(epoch) - msgID := spectypes.NewMsgID(postBooleCfg.DomainTypeAtSlot(acSlot), encodedCommitteeID, spectypes.RoleAggregatorCommittee) + msgID := ssvtestingutils.NewMsgID(postBooleCfg.DomainTypeAtSlot(acSlot), encodedCommitteeID, spectypes.RoleAggregatorCommittee) signedSSVMessage := buildBooleProposal(postBooleCfg, ks, committee, msgID, acSlot) committeeInfo, err := postBooleValidator.getCommitteeAndValidatorIndices(signedSSVMessage.SSVMessage.GetID()) @@ -1926,7 +1927,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) - identifier := spectypes.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), spectypes.RoleProposer) + identifier := ssvtestingutils.NewMsgID(netCfg.DomainType, ks.ValidatorPK.Serialize(), spectypes.RoleProposer) signedSSVMessage := generateSignedMessage(ks, identifier, slot, func(message *specqbft.Message) { message.MsgType = specqbft.PrepareMsgType }) @@ -2141,7 +2142,7 @@ func Test_ValidateSSVMessage(t *testing.T) { dutyExecutorID = encodedCommitteeID } - msgID := spectypes.NewMsgID(netCfg.DomainType, dutyExecutorID, role) + msgID := ssvtestingutils.NewMsgID(netCfg.DomainType, dutyExecutorID, role) signedSSVMessage := generateSignedMessage(ks, msgID, slot, func(message *specqbft.Message) { message.MsgType = specqbft.PrepareMsgType message.Round = round @@ -2319,7 +2320,7 @@ func Test_ValidateSSVMessage(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) signedSSVMessage := generateSignedMessage(ks, committeeIdentifier, slot, func(message *specqbft.Message) { - wrongID := spectypes.NewMsgID(netCfg.DomainType, encodedCommitteeID[:], nonCommitteeRole) + wrongID := ssvtestingutils.NewMsgID(netCfg.DomainType, encodedCommitteeID[:], nonCommitteeRole) message.Identifier = wrongID[:] }) signedSSVMessage.SSVMessage.MsgID = committeeIdentifier @@ -2532,7 +2533,7 @@ func Test_DegenerateCommittee_NeverReachesTopicValidation(t *testing.T) { slot := netCfg.FirstSlotAtEpoch(1) encodedDegenerateCommitteeID := append(bytes.Repeat([]byte{0}, 16), degenerateCommitteeID[:]...) - degenerateIdentifier := spectypes.NewMsgID(netCfg.DomainType, encodedDegenerateCommitteeID, spectypes.RoleCommittee) + degenerateIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainType, encodedDegenerateCommitteeID, spectypes.RoleCommittee) // Confirm the store lookup itself succeeds (committee "exists"), so we know the rejection // below comes from belongsToCommittee, not from ErrNonExistentCommitteeID. @@ -2625,7 +2626,7 @@ func Test_ForkBoundary_TopicParity(t *testing.T) { t.Run("last pre-fork slot accepts alan topic and rejects boole topic", func(t *testing.T) { slot := lastPreForkSlot - identifier := spectypes.NewMsgID(netCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID(netCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleCommittee) // generateSignedMessage hardcodes operator 1 as signer, which is only the leader at // heights where height%len(committee)==0. lastPreForkSlot depends on the wall-clock // current epoch (test setup above), so we must compute the real leader instead of @@ -2647,7 +2648,7 @@ func Test_ForkBoundary_TopicParity(t *testing.T) { t.Run("first post-fork slot accepts boole topic and rejects alan topic", func(t *testing.T) { slot := firstPostForkSlot - identifier := spectypes.NewMsgID(netCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID(netCfg.DomainTypeAtSlot(slot), encodedCommitteeID, spectypes.RoleCommittee) signedSSVMessage := buildBooleProposal(netCfg, ks, committee, identifier, slot) receivedAt := netCfg.SlotStartTime(slot) diff --git a/network/p2p/p2p_test.go b/network/p2p/p2p_test.go index daaabcf0dc..6916426a89 100644 --- a/network/p2p/p2p_test.go +++ b/network/p2p/p2p_test.go @@ -26,6 +26,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/qbft" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestGetMaxPeers(t *testing.T) { @@ -190,7 +191,7 @@ func generateValidatorMsg(ks *spectestingutils.TestKeySet, round specqbft.Round, // Derive the domain per-slot like production (p2p_setup.go DomainTypeAtSlot) so the // fixture stays valid on both sides of the Boole fork (SSV_TEST_BOOLE_FORK matrix). - nonCommitteeIdentifier := spectypes.NewMsgID(netCfg.DomainTypeAtSlot(phase0.Slot(height)), ks.ValidatorPK.Serialize(), nonCommitteeRole) + nonCommitteeIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainTypeAtSlot(phase0.Slot(height)), ks.ValidatorPK.Serialize(), nonCommitteeRole) qbftMessage := &specqbft.Message{ MsgType: specqbft.ProposalMsgType, @@ -224,7 +225,7 @@ func generateCommitteeMsg(ks *spectestingutils.TestKeySet, round specqbft.Round) fullData := spectestingutils.TestingQBFTFullData encodedCommitteeID := append(bytes.Repeat([]byte{0}, 16), committeeID[:]...) - committeeIdentifier := spectypes.NewMsgID(netCfg.DomainTypeAtSlot(phase0.Slot(height)), encodedCommitteeID, spectypes.RoleCommittee) + committeeIdentifier := ssvtestingutils.NewMsgID(netCfg.DomainTypeAtSlot(phase0.Slot(height)), encodedCommitteeID, spectypes.RoleCommittee) qbftMessage := &specqbft.Message{ MsgType: specqbft.ProposalMsgType, @@ -267,7 +268,7 @@ func dummyMsg(t *testing.T, pkHex string, height int, role spectypes.RunnerRole) committeeID := ssvtypes.ComputeCommitteeID([]spectypes.OperatorID{1, 2, 3, 4}) dutyExecutorID = append(bytes.Repeat([]byte{0}, 16), committeeID[:]...) } - id := spectypes.NewMsgID(networkconfig.TestNetwork.DomainTypeAtSlot(phase0.Slot(height)), dutyExecutorID, role) + id := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainTypeAtSlot(phase0.Slot(height)), dutyExecutorID, role) qbftMessage := &specqbft.Message{ MsgType: specqbft.CommitMsgType, diff --git a/network/topics/controller_test.go b/network/topics/controller_test.go index bf16186c45..00a6320951 100644 --- a/network/topics/controller_test.go +++ b/network/topics/controller_test.go @@ -35,6 +35,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/observability/log" "github.com/ssvlabs/ssv/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" registrystorage "github.com/ssvlabs/ssv/registry/storage" "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" @@ -478,7 +479,7 @@ func dummyMsg(pkHex string, height int, malformed bool) (*spectypes.SignedSSVMes return nil, err } - id := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, pk, spectypes.RoleCommittee) + id := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, pk, spectypes.RoleCommittee) signature, err := base64.StdEncoding.DecodeString("sVV0fsvqQlqliKv/ussGIatxpe8LDWhc9uoaM5WpjbiYvvxUr1eCpz0ja7UT1PGNDdmoGi6xbMC1g/ozhAt4uCdpy0Xdfqbv2hMf2iRL5ZPKOSmMifHbd8yg4PeeceyN") if err != nil { return nil, err diff --git a/network/topics/msg_validator_test.go b/network/topics/msg_validator_test.go index 226ea12b52..7c1b7363bb 100644 --- a/network/topics/msg_validator_test.go +++ b/network/topics/msg_validator_test.go @@ -26,6 +26,7 @@ import ( "github.com/ssvlabs/ssv/operator/duties/dutystore" operatorstorage "github.com/ssvlabs/ssv/operator/storage" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/registry/storage" kv "github.com/ssvlabs/ssv/storage/badger" "github.com/ssvlabs/ssv/storage/basedb" @@ -200,7 +201,7 @@ func newPBMsg(data []byte, topic string, from []byte) *pubsub.Message { } func dummySSVConsensusMsg(domainType spectypes.DomainType, dutyExecutorID []byte, height specqbft.Height) (*spectypes.SSVMessage, error) { - id := spectypes.NewMsgID(domainType, dutyExecutorID, spectypes.RoleCommittee) + id := ssvtestingutils.NewMsgID(domainType, dutyExecutorID, spectypes.RoleCommittee) qbftMsg := &specqbft.Message{ MsgType: specqbft.RoundChangeMsgType, Height: height, diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 55766fb120..47bb042eec 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1111,7 +1111,15 @@ func (c *Controller) ReportValidatorStatuses(ctx context.Context) { // height's slot. func newIdentifierFn(cfg *networkconfig.Network, executorID []byte, role spectypes.RunnerRole) func(specqbft.Height) []byte { return func(height specqbft.Height) []byte { - id := spectypes.NewMsgID(cfg.DomainTypeAtSlot(phase0.Slot(height)), executorID, role) + domain := cfg.DomainTypeAtSlot(phase0.Slot(height)) + // executorID is a 32-byte committee ID (committee/aggregator-committee runners) or a + // 48-byte validator pubkey (all other roles); pick the matching typed MsgID constructor. + var id spectypes.MessageID + if len(executorID) == len(spectypes.CommitteeID{}) { + id = spectypes.NewCommitteeMsgID(domain, spectypes.CommitteeID(executorID), role) + } else { + id = spectypes.NewValidatorMsgID(domain, spectypes.ValidatorPK(executorID), role) + } return id[:] } } diff --git a/operator/validator/controller_bench_test.go b/operator/validator/controller_bench_test.go index d6ca5cf1a3..1f9faaee55 100644 --- a/operator/validator/controller_bench_test.go +++ b/operator/validator/controller_bench_test.go @@ -19,6 +19,7 @@ import ( "github.com/ssvlabs/ssv/operator/validators" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" validatorprotocol "github.com/ssvlabs/ssv/protocol/v2/ssv/validator" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) const ( @@ -193,7 +194,7 @@ func benchmarkCommitteeFixture( } func benchmarkRouterMessage(committeeID spectypes.CommitteeID, slot phase0.Slot) *queue.SSVMessage { - msgID := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, committeeID[:], spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, committeeID[:], spectypes.RoleCommittee) qbftMsg := &specqbft.Message{ Height: specqbft.Height(slot), diff --git a/operator/validator/controller_test.go b/operator/validator/controller_test.go index ec7a572d1a..d02ef743b9 100644 --- a/operator/validator/controller_test.go +++ b/operator/validator/controller_test.go @@ -46,6 +46,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv/runner" "github.com/ssvlabs/ssv/protocol/v2/ssv/validator" "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" registrystorage "github.com/ssvlabs/ssv/registry/storage" storagemocks "github.com/ssvlabs/ssv/registry/storage/mocks" kv "github.com/ssvlabs/ssv/storage/badger" @@ -234,7 +235,7 @@ func TestHandleNonCommitteeMessages_RoleGuard(t *testing.T) { return &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), role), + MsgID: ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), role), }, Body: body, } @@ -266,7 +267,7 @@ func TestHandleNonCommitteeMessages_RoleGuard(t *testing.T) { msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.DKGMsgType, - MsgID: spectypes.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleCommittee), + MsgID: ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleCommittee), }, } @@ -387,7 +388,7 @@ func TestHandleNonCommitteeMessages(t *testing.T) { logger.Debug("starting to send messages") - identifier := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte("pk"), spectypes.RoleCommittee) + identifier := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte("pk"), spectypes.RoleCommittee) ctr.messageRouter.Route(t.Context(), &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ @@ -466,7 +467,7 @@ func TestHandleWorkerMessagesUsesMessageTraceHandler(t *testing.T) { return sentinelErr } - msgID := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte("pk"), spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte("pk"), spectypes.RoleCommittee) ssvMsg := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, MsgID: msgID, @@ -1709,7 +1710,7 @@ func TestHandleRouterMessages_LogsOwnValidatorDrop(t *testing.T) { go ctr.handleRouterMessages() route := func(pk []byte, body any) { - msgID := spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, pk, spectypes.RoleProposerPreferences) + msgID := ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, pk, spectypes.RoleProposerPreferences) ctr.messageRouter.Route(t.Context(), &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{MsgType: spectypes.SSVPartialSignatureMsgType, MsgID: msgID, Data: []byte("data")}, Body: body, diff --git a/operator/validator/identifier_fn_test.go b/operator/validator/identifier_fn_test.go index f32369da02..b7baed69b2 100644 --- a/operator/validator/identifier_fn_test.go +++ b/operator/validator/identifier_fn_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) // TestNewIdentifierFn_ForkDomain verifies that the identifier resolver wired into QBFT @@ -41,8 +42,8 @@ func TestNewIdentifierFn_ForkDomain(t *testing.T) { t.Run(tc.name, func(t *testing.T) { identifierFn := newIdentifierFn(cfg, tc.executorID, tc.role) - expectedPreFork := spectypes.NewMsgID(cfg.DomainType, tc.executorID, tc.role) - expectedPostFork := spectypes.NewMsgID(cfg.NextDomainType, tc.executorID, tc.role) + expectedPreFork := ssvtestingutils.NewMsgID(cfg.DomainType, tc.executorID, tc.role) + expectedPostFork := ssvtestingutils.NewMsgID(cfg.NextDomainType, tc.executorID, tc.role) require.NotEqual(t, expectedPreFork, expectedPostFork, "sanity: pre- and post-fork identifiers must differ in domain") diff --git a/operator/validator/router_test.go b/operator/validator/router_test.go index 18a5b07ae7..32f1b7ad58 100644 --- a/operator/validator/router_test.go +++ b/operator/validator/router_test.go @@ -16,6 +16,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/observability/log" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestRouter(t *testing.T) { @@ -50,7 +51,7 @@ func TestRouter(t *testing.T) { msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.MsgType(i % 3), - MsgID: spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), + MsgID: ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), Data: fmt.Appendf(nil, "data-%d", i), }, } @@ -78,7 +79,7 @@ func TestRouter_DropsWhenContextCanceled(t *testing.T) { msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), + MsgID: ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), Data: []byte("data"), }, } @@ -98,7 +99,7 @@ func TestRouter_DropsWhenBufferFull(t *testing.T) { msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), + MsgID: ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), Data: []byte("data"), }, } @@ -177,7 +178,7 @@ func TestRouter_ConcurrentRoute_RecordsAllBufferFullDrops(t *testing.T) { msg := &queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: spectypes.SSVConsensusMsgType, - MsgID: spectypes.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), + MsgID: ssvtestingutils.NewMsgID(networkconfig.TestNetwork.DomainType, []byte{1, 1, 1, 1, 1}, spectypes.RoleCommittee), Data: []byte("data"), }, } diff --git a/protocol/v2/qbft/controller/controller_fork_test.go b/protocol/v2/qbft/controller/controller_fork_test.go index 59b4feeff9..f4bf822433 100644 --- a/protocol/v2/qbft/controller/controller_fork_test.go +++ b/protocol/v2/qbft/controller/controller_fork_test.go @@ -18,6 +18,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/qbft" "github.com/ssvlabs/ssv/protocol/v2/qbft/roundtimer" "github.com/ssvlabs/ssv/protocol/v2/ssv" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) // TestController_IdentifierAtHeight verifies that identifierAtHeight switches the SSV domain @@ -78,12 +79,12 @@ func TestController_ProcessMsg_ForkDomainCheck(t *testing.T) { identifierFn := func(height specqbft.Height) []byte { domain := midBooleCfg.DomainTypeAtSlot(phase0.Slot(height)) - id := spectypes.NewMsgID(domain, committeeID[:], role) + id := ssvtestingutils.NewMsgID(domain, committeeID[:], role) return id[:] } // Static identifier frozen at pre-fork domain (simulates old code path). - staticID := spectypes.NewMsgID(alanDomain, committeeID[:], role) + staticID := ssvtestingutils.NewMsgID(alanDomain, committeeID[:], role) logger := zap.NewNop() @@ -105,7 +106,7 @@ func TestController_ProcessMsg_ForkDomainCheck(t *testing.T) { ctrl.IdentifierFn = identifierFn advanceTo(ctrl, postForkHeight) - booleID := spectypes.NewMsgID(booleDomain, committeeID[:], role) + booleID := ssvtestingutils.NewMsgID(booleDomain, committeeID[:], role) signedMsg := makeSignedQBFTMsg(ks, booleID[:], postForkHeight) _, err := ctrl.ProcessMsg(context.Background(), logger, signedMsg, nil) @@ -120,7 +121,7 @@ func TestController_ProcessMsg_ForkDomainCheck(t *testing.T) { ctrl.IdentifierFn = identifierFn advanceTo(ctrl, preForkHeight) - alanID := spectypes.NewMsgID(alanDomain, committeeID[:], role) + alanID := ssvtestingutils.NewMsgID(alanDomain, committeeID[:], role) signedMsg := makeSignedQBFTMsg(ks, alanID[:], preForkHeight) _, err := ctrl.ProcessMsg(context.Background(), logger, signedMsg, nil) @@ -135,7 +136,7 @@ func TestController_ProcessMsg_ForkDomainCheck(t *testing.T) { ctrl.IdentifierFn = identifierFn advanceTo(ctrl, postForkHeight) - staleAlanID := spectypes.NewMsgID(alanDomain, committeeID[:], role) + staleAlanID := ssvtestingutils.NewMsgID(alanDomain, committeeID[:], role) signedMsg := makeSignedQBFTMsg(ks, staleAlanID[:], postForkHeight) _, err := ctrl.ProcessMsg(context.Background(), logger, signedMsg, nil) @@ -153,7 +154,7 @@ func TestController_ProcessMsg_ForkDomainCheck(t *testing.T) { ctrl.IdentifierFn = identifierFn advanceTo(ctrl, preForkHeight) - wrongBooleID := spectypes.NewMsgID(booleDomain, committeeID[:], role) + wrongBooleID := ssvtestingutils.NewMsgID(booleDomain, committeeID[:], role) signedMsg := makeSignedQBFTMsg(ks, wrongBooleID[:], preForkHeight) _, err := ctrl.ProcessMsg(context.Background(), logger, signedMsg, nil) @@ -175,7 +176,7 @@ func TestController_NilIdentifierFn_ByteIdenticalToStatic(t *testing.T) { committeeID := member.CommitteeID preBooleCfg := networkconfig.TestNetwork // Boole at MaxUint64 - staticID := spectypes.NewMsgID(preBooleCfg.DomainType, committeeID[:], role) + staticID := ssvtestingutils.NewMsgID(preBooleCfg.DomainType, committeeID[:], role) ctrl := NewController(staticID[:], member, nil, nil, false) // IdentifierFn intentionally left nil. @@ -223,7 +224,7 @@ func newForkTestHarness() *forkTestHarness { identifierFn := func(height specqbft.Height) []byte { domain := midBooleCfg.DomainTypeAtSlot(phase0.Slot(height)) - id := spectypes.NewMsgID(domain, committeeID[:], role) + id := ssvtestingutils.NewMsgID(domain, committeeID[:], role) return id[:] } @@ -234,7 +235,7 @@ func newForkTestHarness() *forkTestHarness { preForkHeight: specqbft.Height(phase0.Slot(forkEpoch)*slotsPerEpoch - 1), postForkHeight: specqbft.Height(phase0.Slot(forkEpoch) * slotsPerEpoch), identifierFn: identifierFn, - staticID: spectypes.NewMsgID(midBooleCfg.DomainType, committeeID[:], role), + staticID: ssvtestingutils.NewMsgID(midBooleCfg.DomainType, committeeID[:], role), roundTimerF: func(ctx context.Context, logger *zap.Logger, slot phase0.Slot) ssv.QBFTRoundTimer { return roundtimer.NewTestingTimer() }, diff --git a/protocol/v2/queue/worker/message_worker_test.go b/protocol/v2/queue/worker/message_worker_test.go index 0f1ff2c889..86690a76cf 100644 --- a/protocol/v2/queue/worker/message_worker_test.go +++ b/protocol/v2/queue/worker/message_worker_test.go @@ -18,6 +18,7 @@ import ( "github.com/ssvlabs/ssv/observability/log" "github.com/ssvlabs/ssv/protocol/v2/ssv/queue" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" ) func TestWorker(t *testing.T) { @@ -172,7 +173,7 @@ func TestMessageContextFields(t *testing.T) { }) t.Run("committee message includes slot and committee id", func(t *testing.T) { - msgID := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) fields := messageContextFields(&queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgID: msgID, @@ -189,7 +190,7 @@ func TestMessageContextFields(t *testing.T) { }) t.Run("validator message omits slot and committee id when slot unavailable", func(t *testing.T) { - msgID := spectypes.NewMsgID([4]byte{}, []byte("validator_pk"), ssvtypes.RoleAggregator) + msgID := ssvtestingutils.NewMsgID([4]byte{}, []byte("validator_pk"), ssvtypes.RoleAggregator) fields := messageContextFields(&queue.SSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgID: msgID, @@ -208,7 +209,7 @@ func TestMessageContextFields(t *testing.T) { func TestWorkerProcess_LogsMessageContextOnError(t *testing.T) { core, recorded := observer.New(zap.DebugLevel) logger := zap.New(core) - msgID := spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) + msgID := ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee) worker := &Worker{ handler: func(context.Context, network.DecodedSSVMessage) error { diff --git a/protocol/v2/ssv/queue/message_prioritizer_test.go b/protocol/v2/ssv/queue/message_prioritizer_test.go index 8891ed1df7..1ff29ea205 100644 --- a/protocol/v2/ssv/queue/message_prioritizer_test.go +++ b/protocol/v2/ssv/queue/message_prioritizer_test.go @@ -18,6 +18,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/message" "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/utils/casts" ) @@ -375,7 +376,7 @@ func (m mockExecuteDutyMessage) ssvMessage(state *State) *spectypes.SignedSSVMes return &spectypes.SignedSSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: message.SSVEventMsgType, - MsgID: spectypes.NewMsgID(testingutils.TestingSSVDomainType, testingutils.TestingValidatorPubKey[:], casts.BeaconRoleToRunnerRole(m.Role)), + MsgID: ssvtestingutils.NewMsgID(testingutils.TestingSSVDomainType, testingutils.TestingValidatorPubKey[:], casts.BeaconRoleToRunnerRole(m.Role)), Data: data, }, FullData: []byte{1, 2, 3, 4}, @@ -405,7 +406,7 @@ func (m mockTimeoutMessage) ssvMessage(state *State) *spectypes.SignedSSVMessage return &spectypes.SignedSSVMessage{ SSVMessage: &spectypes.SSVMessage{ MsgType: message.SSVEventMsgType, - MsgID: spectypes.NewMsgID(testingutils.TestingSSVDomainType, testingutils.TestingValidatorPubKey[:], m.Role), + MsgID: ssvtestingutils.NewMsgID(testingutils.TestingSSVDomainType, testingutils.TestingValidatorPubKey[:], m.Role), Data: eventMsgData, }, FullData: []byte{1, 2, 3, 4}, diff --git a/protocol/v2/ssv/runner/aggregator.go b/protocol/v2/ssv/runner/aggregator.go index 85c2122215..f628d2d434 100644 --- a/protocol/v2/ssv/runner/aggregator.go +++ b/protocol/v2/ssv/runner/aggregator.go @@ -252,7 +252,7 @@ func (r *AggregatorRunner) ProcessConsensus(ctx context.Context, logger *zap.Log } domain := r.NetworkConfig.DomainTypeAtSlot(decidedValue.Duty.Slot) - msgID := spectypes.NewMsgID(domain, r.GetShare().ValidatorPubKey[:], r.RunnerRoleType) + msgID := spectypes.NewValidatorMsgID(domain, r.GetShare().ValidatorPubKey, r.RunnerRoleType) encodedMsg, err := postConsensusMsg.Encode() if err != nil { @@ -458,7 +458,7 @@ func (r *AggregatorRunner) executeDuty(ctx context.Context, logger *zap.Logger, logger.Debug("signing and broadcasting selection proof partial sig", fields.Slot(validatorDuty.DutySlot())) r.measurements.StartPreConsensus() - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast selection proof partial sig: %w", err) } diff --git a/protocol/v2/ssv/runner/aggregator_committee.go b/protocol/v2/ssv/runner/aggregator_committee.go index 34e7676d6c..0a391282bb 100644 --- a/protocol/v2/ssv/runner/aggregator_committee.go +++ b/protocol/v2/ssv/runner/aggregator_committee.go @@ -825,9 +825,9 @@ func (r *AggregatorCommitteeRunner) ProcessConsensus( ssvMsg := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID( + MsgID: spectypes.NewCommitteeMsgID( r.NetworkConfig.DomainTypeAtSlot(duty.DutySlot()), - r.QBFTController.CommitteeMember.CommitteeID[:], + r.QBFTController.CommitteeMember.CommitteeID, r.RunnerRoleType, ), } @@ -1772,9 +1772,9 @@ func (r *AggregatorCommitteeRunner) executeDuty(ctx context.Context, logger *zap return nil } - msgID := spectypes.NewMsgID( + msgID := spectypes.NewCommitteeMsgID( r.NetworkConfig.DomainTypeAtSlot(duty.DutySlot()), - r.QBFTController.CommitteeMember.CommitteeID[:], + r.QBFTController.CommitteeMember.CommitteeID, r.RunnerRoleType, ) encodedMsg, err := msg.Encode() diff --git a/protocol/v2/ssv/runner/aggregator_postconsensus_classification_test.go b/protocol/v2/ssv/runner/aggregator_postconsensus_classification_test.go index 6a181ee669..f615c87cc3 100644 --- a/protocol/v2/ssv/runner/aggregator_postconsensus_classification_test.go +++ b/protocol/v2/ssv/runner/aggregator_postconsensus_classification_test.go @@ -20,6 +20,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv" protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -48,7 +49,7 @@ func newAggregatorRunnerEnv(t *testing.T, beaconNode beacon.BeaconNode) *aggrega config := protocoltesting.TestingConfig(logger, keySet) config.Network = network - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], ssvtypes.RoleAggregator) + identifier := ssvtestingutils.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], ssvtypes.RoleAggregator) ctrl := protocoltesting.NewTestingQBFTController( keySet, identifier[:], diff --git a/protocol/v2/ssv/runner/committee.go b/protocol/v2/ssv/runner/committee.go index f4e6df84f7..fcecfe181f 100644 --- a/protocol/v2/ssv/runner/committee.go +++ b/protocol/v2/ssv/runner/committee.go @@ -425,9 +425,9 @@ listener: ssvMsg := &spectypes.SSVMessage{ MsgType: spectypes.SSVPartialSignatureMsgType, - MsgID: spectypes.NewMsgID( + MsgID: spectypes.NewCommitteeMsgID( r.NetworkConfig.DomainTypeAtSlot(r.State.CurrentDuty.DutySlot()), - r.QBFTController.CommitteeMember.CommitteeID[:], + r.QBFTController.CommitteeMember.CommitteeID, r.RunnerRoleType, ), } diff --git a/protocol/v2/ssv/runner/envelope.go b/protocol/v2/ssv/runner/envelope.go index 91277b5d94..f8f73a2f1f 100644 --- a/protocol/v2/ssv/runner/envelope.go +++ b/protocol/v2/ssv/runner/envelope.go @@ -146,7 +146,7 @@ func (r *EnvelopeProposerRunner) ProcessConsensus(ctx context.Context, logger *z r.measurements.StartPostConsensus() span.AddEvent("broadcasting post-consensus partial signature message") - if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey[:], postConsensusMsg); err != nil { + if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey, postConsensusMsg); err != nil { return fmt.Errorf("can't broadcast partial post-consensus sig: %w", err) } diff --git a/protocol/v2/ssv/runner/envelope_e2e_test.go b/protocol/v2/ssv/runner/envelope_e2e_test.go index e2f74d382e..325b615430 100644 --- a/protocol/v2/ssv/runner/envelope_e2e_test.go +++ b/protocol/v2/ssv/runner/envelope_e2e_test.go @@ -17,6 +17,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv" protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -41,7 +42,7 @@ func newEnvelopeProposerRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*Enve cfg := cloneTestNetworkConfig() keySet := spectestingutils.Testing4SharesSet() share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleEnvelopeProposer) + identifier := ssvtestingutils.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleEnvelopeProposer) network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) operator := spectestingutils.TestingCommitteeMember(keySet) @@ -88,7 +89,7 @@ func setupEnvelopeRunnerForPostConsensus(t *testing.T, runner *EnvelopeProposerR require.NoError(t, err) runner.State.DecidedValue = encoded - msgID := spectypes.NewMsgID(runner.NetworkConfig.DomainType, runner.GetShare().ValidatorPubKey[:], runner.RunnerRoleType) + msgID := ssvtestingutils.NewMsgID(runner.NetworkConfig.DomainType, runner.GetShare().ValidatorPubKey[:], runner.RunnerRoleType) qbftConfig := protocoltesting.TestingConfig(zap.NewNop(), keySet) qbftConfig.ProposerF = func(*specqbft.State, specqbft.Round) spectypes.OperatorID { return 1 } qbftConfig.Network = runner.network diff --git a/protocol/v2/ssv/runner/preconsensus_domain_test.go b/protocol/v2/ssv/runner/preconsensus_domain_test.go index c1c5c3d944..e6c601dd09 100644 --- a/protocol/v2/ssv/runner/preconsensus_domain_test.go +++ b/protocol/v2/ssv/runner/preconsensus_domain_test.go @@ -96,10 +96,10 @@ func TestSignAndBroadcastPartialSigMsgsDomainIsForkAware(t *testing.T) { }) } -// spectestingValidatorPubKey returns a 48-byte public key for use in tests. -// The content is arbitrary; only the domain portion of the MsgID is under test. -func spectestingValidatorPubKey() []byte { - key := make([]byte, 48) +// spectestingValidatorPubKey returns an arbitrary validator public key for use in tests; +// only the domain portion of the MsgID is under test. +func spectestingValidatorPubKey() spectypes.ValidatorPK { + var key spectypes.ValidatorPK key[0] = 0xab return key } diff --git a/protocol/v2/ssv/runner/proposer.go b/protocol/v2/ssv/runner/proposer.go index 6a1ab360ce..ea784cf9bc 100644 --- a/protocol/v2/ssv/runner/proposer.go +++ b/protocol/v2/ssv/runner/proposer.go @@ -437,7 +437,7 @@ func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logge r.measurements.StartPostConsensus() span.AddEvent("broadcasting post consensus partial signature message") - if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey[:], postConsensusMsg); err != nil { + if err := r.signAndBroadcastPostConsensusMsg(r.GetNetwork(), r.operatorSigner, r.GetShare().ValidatorPubKey, postConsensusMsg); err != nil { return fmt.Errorf("can't broadcast partial post consensus sig: %w", err) } const broadcastedPostConsensusMsgEvent = "broadcasted post-consensus partial signature message" @@ -746,7 +746,7 @@ func (r *ProposerRunner) executeDuty(ctx context.Context, logger *zap.Logger, du logger.Debug("signing and broadcasting randao partial sig", fields.Slot(proposerDuty.DutySlot())) r.measurements.StartPreConsensus() - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast randao partial sig: %w", err) } diff --git a/protocol/v2/ssv/runner/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go index 4f9685090d..8931a0163a 100644 --- a/protocol/v2/ssv/runner/proposer_preferences.go +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -547,7 +547,7 @@ func (r *proposerPreferencesSlotRunner) executeDuty(ctx context.Context, logger Messages: []*spectypes.PartialSignatureMessage{msg}, } - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast proposer preferences partial sig: %w", err) } r.broadcastPreferences = preferences diff --git a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go index e914721bd1..05d9924bdc 100644 --- a/protocol/v2/ssv/runner/proposer_preferences_request_auth.go +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -86,7 +86,7 @@ func (r *proposerPreferencesSlotRunner) runRequestAuthRound(ctx context.Context, Slot: proposalSlot, Messages: []*spectypes.PartialSignatureMessage{msg}, } - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { logger.Warn("request auth skipped: could not broadcast partial", fields.Slot(proposalSlot), zap.String("builder_url", entry.URL), zap.Error(err)) continue diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 3b14f5e2f8..9bcdbfa04c 100644 --- a/protocol/v2/ssv/runner/proposer_test.go +++ b/protocol/v2/ssv/runner/proposer_test.go @@ -26,6 +26,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv" protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" "github.com/ssvlabs/ssv/protocol/v2/types/gloas" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -493,7 +494,7 @@ func newProposerRunnerForTest( logger := zap.NewNop() keySet := spectestingutils.Testing4SharesSet() share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleProposer) + identifier := ssvtestingutils.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], spectypes.RoleProposer) network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) operator := spectestingutils.TestingCommitteeMember(keySet) @@ -568,7 +569,7 @@ func setupRunnerForPostConsensus( require.NoError(t, err) runner.State.DecidedValue = encodedDecidedValue - msgID := spectypes.NewMsgID(runner.NetworkConfig.DomainType, runner.GetShare().ValidatorPubKey[:], runner.RunnerRoleType) + msgID := ssvtestingutils.NewMsgID(runner.NetworkConfig.DomainType, runner.GetShare().ValidatorPubKey[:], runner.RunnerRoleType) qbftConfig := protocoltesting.TestingConfig(zap.NewNop(), keySet) qbftConfig.ProposerF = func(state *specqbft.State, round specqbft.Round) spectypes.OperatorID { return leaderID diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go index 098bff2a3e..884dd2dcc7 100644 --- a/protocol/v2/ssv/runner/ptc_attester.go +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -191,7 +191,7 @@ func (r *PTCAttesterRunner) executeDuty(ctx context.Context, logger *zap.Logger, Messages: []*spectypes.PartialSignatureMessage{msg}, } - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast payload attestation partial sig: %w", err) } return nil diff --git a/protocol/v2/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index 248583cb0c..f1410abdff 100644 --- a/protocol/v2/ssv/runner/runner.go +++ b/protocol/v2/ssv/runner/runner.go @@ -397,7 +397,7 @@ func (b *BaseRunner) signAndBroadcastPartialSigMsgs( ctx context.Context, network protocolp2p.Network, opSigner ssvtypes.OperatorSigner, - validatorPubKey []byte, + validatorPubKey spectypes.ValidatorPK, msgs *spectypes.PartialSignatureMessages, ) error { // Reuse the existing span instead of generating new one to keep tracing-data lightweight. @@ -406,7 +406,7 @@ func (b *BaseRunner) signAndBroadcastPartialSigMsgs( // Use the fork-aware domain so the pubsub message validator accepts the message after the // Boole fork activates (post-fork it checks NextDomainType). Mirrors CommitteeRunner and // QBFT domain selection. Fixes #2915. - msgID := spectypes.NewMsgID(b.NetworkConfig.DomainTypeAtSlot(msgs.Slot), validatorPubKey, b.RunnerRoleType) + msgID := spectypes.NewValidatorMsgID(b.NetworkConfig.DomainTypeAtSlot(msgs.Slot), validatorPubKey, b.RunnerRoleType) encodedMsg, err := msgs.Encode() if err != nil { return fmt.Errorf("could not encode partial signature messages: %w", err) @@ -444,11 +444,11 @@ func (b *BaseRunner) signAndBroadcastPartialSigMsgs( func (b *BaseRunner) signAndBroadcastPostConsensusMsg( network protocolp2p.Network, opSigner ssvtypes.OperatorSigner, - validatorPubKey []byte, + validatorPubKey spectypes.ValidatorPK, msgs *spectypes.PartialSignatureMessages, ) error { domain := b.NetworkConfig.DomainTypeAtSlot(msgs.Slot) - msgID := spectypes.NewMsgID(domain, validatorPubKey, b.RunnerRoleType) + msgID := spectypes.NewValidatorMsgID(domain, validatorPubKey, b.RunnerRoleType) encodedMsg, err := msgs.Encode() if err != nil { return fmt.Errorf("could not encode post-consensus partial signature message: %w", err) diff --git a/protocol/v2/ssv/runner/sync_committee_contribution.go b/protocol/v2/ssv/runner/sync_committee_contribution.go index 85e39c51ff..4ab844dd6e 100644 --- a/protocol/v2/ssv/runner/sync_committee_contribution.go +++ b/protocol/v2/ssv/runner/sync_committee_contribution.go @@ -273,7 +273,7 @@ func (r *SyncCommitteeAggregatorRunner) ProcessConsensus(ctx context.Context, lo } domain := r.NetworkConfig.DomainTypeAtSlot(cd.Duty.Slot) - msgID := spectypes.NewMsgID(domain, r.GetShare().ValidatorPubKey[:], r.RunnerRoleType) + msgID := spectypes.NewValidatorMsgID(domain, r.GetShare().ValidatorPubKey, r.RunnerRoleType) encodedMsg, err := postConsensusMsg.Encode() if err != nil { @@ -579,7 +579,7 @@ func (r *SyncCommitteeAggregatorRunner) executeDuty(ctx context.Context, logger logger.Debug("signing and broadcasting contribution proof partial sig", fields.Slot(validatorDuty.DutySlot())) r.measurements.StartPreConsensus() - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast contribution proof partial sig: %w", err) } diff --git a/protocol/v2/ssv/runner/sync_committee_contribution_preconsensus_flow_test.go b/protocol/v2/ssv/runner/sync_committee_contribution_preconsensus_flow_test.go index 85622fbaa9..930927ea3e 100644 --- a/protocol/v2/ssv/runner/sync_committee_contribution_preconsensus_flow_test.go +++ b/protocol/v2/ssv/runner/sync_committee_contribution_preconsensus_flow_test.go @@ -22,6 +22,7 @@ import ( "github.com/ssvlabs/ssv/protocol/v2/ssv" protocoltesting "github.com/ssvlabs/ssv/protocol/v2/testing" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -160,7 +161,7 @@ func newSyncCommitteeAggregatorRunnerForTest( logger := zap.NewNop() keySet := spectestingutils.Testing4SharesSet() share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], ssvtypes.RoleSyncCommitteeContribution) + identifier := ssvtestingutils.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], ssvtypes.RoleSyncCommitteeContribution) network := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) operator := spectestingutils.TestingCommitteeMember(keySet) diff --git a/protocol/v2/ssv/runner/validator_registration.go b/protocol/v2/ssv/runner/validator_registration.go index 93906161d3..04e4f4648a 100644 --- a/protocol/v2/ssv/runner/validator_registration.go +++ b/protocol/v2/ssv/runner/validator_registration.go @@ -229,7 +229,7 @@ func (r *ValidatorRegistrationRunner) executeDuty(ctx context.Context, logger *z logger.Debug("signing and broadcasting validator registration partial sig", zap.Any("validator_registration", vr)) - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast validator registration partial sig: %w", err) } diff --git a/protocol/v2/ssv/runner/voluntary_exit.go b/protocol/v2/ssv/runner/voluntary_exit.go index d7e41303d5..2975256ddc 100644 --- a/protocol/v2/ssv/runner/voluntary_exit.go +++ b/protocol/v2/ssv/runner/voluntary_exit.go @@ -211,7 +211,7 @@ func (r *VoluntaryExitRunner) executeDuty(ctx context.Context, logger *zap.Logge logger.Debug("signing and broadcasting voluntary exit partial sig", fields.Slot(duty.DutySlot())) - if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey[:], msgs); err != nil { + if err := r.signAndBroadcastPartialSigMsgs(ctx, r.network, r.operatorSigner, r.GetShare().ValidatorPubKey, msgs); err != nil { return fmt.Errorf("could not sign/broadcast voluntary exit partial sig: %w", err) } diff --git a/protocol/v2/ssv/testing/runner.go b/protocol/v2/ssv/testing/runner.go index c392db8dbf..7e9262d3b0 100644 --- a/protocol/v2/ssv/testing/runner.go +++ b/protocol/v2/ssv/testing/runner.go @@ -1,7 +1,6 @@ package testing import ( - "bytes" "fmt" "github.com/attestantio/go-eth2-client/spec" @@ -87,7 +86,7 @@ var ConstructBaseRunner = func( keySet *spectestingutils.TestKeySet, ) (runner.Runner, error) { share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) - identifier := spectypes.NewMsgID(spectypes.JatoTestnet, spectestingutils.TestingValidatorPubKey[:], role) + identifier := spectypes.NewValidatorMsgID(spectypes.JatoTestnet, spectypes.ValidatorPK(spectestingutils.TestingValidatorPubKey), role) net := protocoltesting.NewTestingNetwork(1, keySet.OperatorKeys[1]) km := ekm.NewTestingKeyManagerAdapter(spectestingutils.NewTestingKeyManager()) operator := spectestingutils.TestingCommitteeMember(keySet) @@ -259,23 +258,19 @@ var ConstructBaseRunnerWithShareMap = func( sharePubKeys = append(sharePubKeys, phase0.BLSPubKey(share.SharePubKey)) } - // Identifier - var ownerID []byte + // Identifier: committee and aggregator-committee runners key by CommitteeID; all others by validator pubkey. switch role { case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee: - // Committee-scoped identifiers: use CommitteeID for both committee and aggregator-committee runners ops := keySetInstance.Committee() committee := make([]uint64, 0, len(ops)) for _, op := range ops { committee = append(committee, op.Signer) } committeeID := spectypes.GetCommitteeID(committee) - ownerID = bytes.Clone(committeeID[:]) + identifier = spectypes.NewCommitteeMsgID(spectestingutils.TestingSSVDomainType, committeeID, role) default: - // Validator-scoped identifiers - ownerID = spectestingutils.TestingValidatorPubKey[:] + identifier = spectypes.NewValidatorMsgID(spectestingutils.TestingSSVDomainType, spectypes.ValidatorPK(spectestingutils.TestingValidatorPubKey), role) } - identifier = spectypes.NewMsgID(spectestingutils.TestingSSVDomainType, ownerID, role) net = protocoltesting.NewTestingNetwork(1, keySetInstance.OperatorKeys[1]) diff --git a/protocol/v2/ssv/validator/committee.go b/protocol/v2/ssv/validator/committee.go index 8c83c0b9f1..68472d3612 100644 --- a/protocol/v2/ssv/validator/committee.go +++ b/protocol/v2/ssv/validator/committee.go @@ -561,7 +561,7 @@ func (c *Committee) createRunner( // than the current wall-clock slot, so a pre-fork duty still running after the fork keys its // timer events under the right domain. Only GetRoleType() is read from this ID downstream, so // this is a consistency fix, not a behavior change today. - runnerIdentifier := spectypes.NewMsgID(c.networkConfig.DomainTypeAtSlot(duty.DutySlot()), c.CommitteeMember.CommitteeID[:], role) + runnerIdentifier := spectypes.NewCommitteeMsgID(c.networkConfig.DomainTypeAtSlot(duty.DutySlot()), c.CommitteeMember.CommitteeID, role) // The typed-nil checks below complement the interface-nil guard above: a CreateRunnerFn // returning a nil *runner.CommitteeRunner behind a non-nil interface passes that guard but diff --git a/protocol/v2/ssv/validator/committee_observer_test.go b/protocol/v2/ssv/validator/committee_observer_test.go index 4d8a1b10c0..a1891bafc5 100644 --- a/protocol/v2/ssv/validator/committee_observer_test.go +++ b/protocol/v2/ssv/validator/committee_observer_test.go @@ -17,6 +17,7 @@ import ( "go.uber.org/zap/zaptest/observer" "github.com/ssvlabs/ssv/protocol/v2/ssv" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" registrystoragemocks "github.com/ssvlabs/ssv/registry/storage/mocks" ) @@ -39,7 +40,7 @@ func TestCommitteeObserver_VerifySig_MissingValidatorLogsContext(t *testing.T) { validatorStore.EXPECT().ValidatorByIndex(missingIndex).Return(nil, false) ncv := &CommitteeObserver{ - msgID: spectypes.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee), + msgID: ssvtestingutils.NewMsgID([4]byte{}, []byte("committee_pk"), spectypes.RoleCommittee), logger: logger, ValidatorStore: validatorStore, postConsensusContainer: map[phase0.Slot]map[phase0.ValidatorIndex]*ssv.PartialSigContainer{ diff --git a/protocol/v2/ssv/validator/duty_executor.go b/protocol/v2/ssv/validator/duty_executor.go index 499327a18e..9dc1e73e34 100644 --- a/protocol/v2/ssv/validator/duty_executor.go +++ b/protocol/v2/ssv/validator/duty_executor.go @@ -94,12 +94,12 @@ func createDutyExecuteMsg( return nil, fmt.Errorf("failed to marshal execute duty data: %w", err) } - return dutyDataToSSVMsg(domain, pubKey[:], runnerRole, data) + return dutyDataToSSVMsg(domain, spectypes.ValidatorPK(pubKey), runnerRole, data) } func dutyDataToSSVMsg( domain spectypes.DomainType, - msgIdentifier []byte, + validatorPK spectypes.ValidatorPK, runnerRole spectypes.RunnerRole, data []byte, ) (*spectypes.SSVMessage, error) { @@ -114,7 +114,7 @@ func dutyDataToSSVMsg( return &spectypes.SSVMessage{ MsgType: message.SSVEventMsgType, - MsgID: spectypes.NewMsgID(domain, msgIdentifier, runnerRole), + MsgID: spectypes.NewValidatorMsgID(domain, validatorPK, runnerRole), Data: msgData, }, nil } diff --git a/protocol/v2/ssv/validator/startup.go b/protocol/v2/ssv/validator/startup.go index 61da800871..ec7cf718a1 100644 --- a/protocol/v2/ssv/validator/startup.go +++ b/protocol/v2/ssv/validator/startup.go @@ -28,7 +28,7 @@ func (v *Validator) Start() (started bool, err error) { if err := n.Subscribe(v.Share.ValidatorPubKey); err != nil { return false, err } - runnerIdentifier := spectypes.NewMsgID(v.NetworkConfig.DomainType, v.Share.ValidatorPubKey[:], role) + runnerIdentifier := spectypes.NewValidatorMsgID(v.NetworkConfig.DomainType, v.Share.ValidatorPubKey, role) v.StartQueueConsumer(runnerIdentifier, v.ProcessMessage) } diff --git a/protocol/v2/ssv/validator/validator.go b/protocol/v2/ssv/validator/validator.go index 07195020bb..e0e4c52f4a 100644 --- a/protocol/v2/ssv/validator/validator.go +++ b/protocol/v2/ssv/validator/validator.go @@ -79,7 +79,7 @@ func NewValidator(ctx context.Context, cancel func(), logger *zap.Logger, option // some additional steps to prepare duty runners for handling duties for role, dutyRunner := range options.DutyRunners { - runnerIdentifier := spectypes.NewMsgID(v.NetworkConfig.DomainType, v.Share.ValidatorPubKey[:], role) + runnerIdentifier := spectypes.NewValidatorMsgID(v.NetworkConfig.DomainType, v.Share.ValidatorPubKey, role) dutyRunner.SetQBFTRoundTimerF(v.newQBFTRoundTimerF(runnerIdentifier)) v.Queues[role] = queue.New( logger, diff --git a/protocol/v2/types/ssvtestingutils/message_id.go b/protocol/v2/types/ssvtestingutils/message_id.go new file mode 100644 index 0000000000..c810b79f22 --- /dev/null +++ b/protocol/v2/types/ssvtestingutils/message_id.go @@ -0,0 +1,30 @@ +// Package ssvtestingutils holds SSV-side helpers used only by tests. +package ssvtestingutils + +import ( + spectypes "github.com/ssvlabs/ssv-spec/types" +) + +// NewMsgID builds a spectypes.MessageID from a domain, an arbitrary-length duty-executor id and a +// role, right-aligning the executor id in the executor slot. +// +// It reproduces the removed spectypes.NewMsgID (ssv-spec split it into the fixed-size +// NewValidatorMsgID/NewCommitteeMsgID, which production code now uses). Tests still need the +// length-agnostic form to build synthetic or malformed ids whose executor is neither a full +// validator pubkey nor a committee id; for those two sizes the output is byte-identical to the +// typed constructors. +func NewMsgID(domain spectypes.DomainType, dutyExecutorID []byte, role spectypes.RunnerRole) spectypes.MessageID { + // Delegate the domain+role bytes (with a zeroed executor slot) to the typed constructor, then + // right-align the arbitrary-length executor id in that slot, as the removed spectypes.NewMsgID did. + mid := spectypes.NewValidatorMsgID(domain, spectypes.ValidatorPK{}, role) + + execEnd := len(mid) + execStart := execEnd - len(spectypes.ValidatorPK{}) + start := execEnd - len(dutyExecutorID) + if start < execStart { + start = execStart + } + copy(mid[start:execEnd], dutyExecutorID) + + return mid +} diff --git a/ssvsigner/go.mod b/ssvsigner/go.mod index f853c6104f..006e02bb77 100644 --- a/ssvsigner/go.mod +++ b/ssvsigner/go.mod @@ -30,7 +30,7 @@ require ( github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 github.com/sourcegraph/conc v0.3.0 github.com/ssvlabs/eth2-key-manager v1.5.6 - github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab + github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/valyala/fasthttp v1.58.0 diff --git a/ssvsigner/go.sum b/ssvsigner/go.sum index a2f4ee4f99..25a80c414b 100644 --- a/ssvsigner/go.sum +++ b/ssvsigner/go.sum @@ -187,12 +187,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/ssvlabs/eth2-key-manager v1.5.6 h1:BMxVCsbcIlUiiO0hpePkHxzX0yhKgMkEzVSoNmSXySM= github.com/ssvlabs/eth2-key-manager v1.5.6/go.mod h1:tjzhmMzrc0Lzc/OMW1h9Mz8AhmKH7FQC/nFiMNJ0bd8= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4 h1:PMwmRhbM50CcrdGHyhOZ9uEET58FQ0DVWMlMK1Y9V0I= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260623204847-d1675a2cc6e4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063 h1:Z9cJtaEz/MkeXWC91beLstQpFWP+1UphGUGr8HqyZz8= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260728180200-ac6b42337063/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab h1:qwxLRgbxrFP47FlIc2zqczIW/q/B5wIOZvYNS1rpgH4= -github.com/ssvlabs/ssv-spec v1.2.3-0.20260825170036-c071cf778fab/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4 h1:kH8KBv49TuA1PA+ZaQv1ljMewP/trobX/R5U2xS1AQA= +github.com/ssvlabs/ssv-spec v1.2.3-0.20260827132058-5461cb30a7f4/go.mod h1:GedhFYGHVJRYYH3nEp05Gn14tyvg6VbTbaIxrMtI7Cg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=