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 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 diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index fb88fe0b26..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 @@ -49,11 +51,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.waitTwoThirdsIntoSlot(ctx, slot); err != nil { - return nil, 0, fmt.Errorf("wait for 2/3 of slot: %w", err) + if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { + return nil, 0, fmt.Errorf("wait for aggregation deadline: %w", err) } va, _, err := gc.fetchVersionedAggregate(ctx, slot, committeeIndex) @@ -82,32 +84,83 @@ func (gc *GoClient) SubmitSignedAggregateSelectionProof( 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. +// waitIntoSlot waits until the given number of intervals into the slot has transpired +// (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)) + 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 (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: 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() + 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 { + attData.Index = committeeIndex + } } 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 } - return root, nil + flipped, err := attData.HashTreeRoot() + if err != nil { + return root, nil, fmt.Errorf("hash flipped attestation data root: %w", err) + } + return root, &flipped, nil } // fetchVersionedAggregate fetches the aggregate attestation for the given slot/committee, @@ -118,37 +171,66 @@ 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, 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) { @@ -266,21 +348,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..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" @@ -283,12 +284,12 @@ func TestSubmitAggregateSelectionProof_RespectsContextCancellationWhileWaiting(t errCh <- err }() - time.Sleep(cfg.IntervalDuration()) + time.Sleep(cfg.IntervalDuration(0)) cancel() 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()) }) @@ -867,6 +868,152 @@ 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) + + 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 + } + 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 { cfg := *networkconfig.TestNetwork.Beacon cfg.GenesisTime = genesisTime diff --git a/beacon/goclient/attest.go b/beacon/goclient/attest.go index 1a57253cf5..3b51d4bac0 100644 --- a/beacon/goclient/attest.go +++ b/beacon/goclient/attest.go @@ -71,12 +71,19 @@ 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) + + // 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 } - 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) @@ -93,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, gloasHTTPClient, 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 := jsonDo(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. @@ -129,10 +167,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 +181,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 +210,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 +465,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 +477,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 +520,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 +533,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..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" @@ -19,6 +20,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/utils/hashmap" ) @@ -93,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 @@ -887,3 +919,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)) +} diff --git a/beacon/goclient/builder_preferences.go b/beacon/goclient/builder_preferences.go new file mode 100644 index 0000000000..983dca7ef5 --- /dev/null +++ b/beacon/goclient/builder_preferences.go @@ -0,0 +1,44 @@ +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. 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() + + return gc.multiClientSubmit(ctx, "SubmitBuilderPreferences", func(ctx context.Context, client Client) error { + return submitBuilderPreferences(ctx, gloasHTTPClient, 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{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) + } + 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/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/gloas_envelope.go b/beacon/goclient/gloas_envelope.go new file mode 100644 index 0000000000..ec17db9e24 --- /dev/null +++ b/beacon/goclient/gloas_envelope.go @@ -0,0 +1,79 @@ +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, merged 2026-06-29). Produce takes the +// 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" +) + +// 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 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, 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 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 { + return fmt.Errorf("marshal signed execution payload envelope: %w", err) + } + + 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) + }) +} + +// 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, 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 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 { + headers := map[string]string{"Eth-Blob-Data-Included": "false"} + _, err := gloasOctetStreamHTTP(ctx, http.MethodPost, addr+gloasPublishEnvelopePath, envelopeSSZ, headers) + if isAlreadyKnown(err) { + return nil + } + return err +} diff --git a/beacon/goclient/gloas_envelope_test.go b/beacon/goclient/gloas_envelope_test.go new file mode 100644 index 0000000000..4baba006e1 --- /dev/null +++ b/beacon/goclient/gloas_envelope_test.go @@ -0,0 +1,119 @@ +package goclient + +import ( + "context" + "io" + "net/http" + "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" +) + +// 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: &gloas.ExecutionRequests{}, + BuilderIndex: gloas.BuilderIndexSelfBuild, + } +} + +func TestRequestExecutionPayloadEnvelope(t *testing.T) { + envelopeSSZ, err := minimalExecutionPayloadEnvelope().MarshalSSZ() + require.NoError(t, err) + + var gotMethod, gotPath, gotAccept string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + 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) + // 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, 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") + gotBlobDataIncluded = r.Header.Get("Eth-Blob-Data-Included") + 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_envelopes", gotPath) + require.Equal(t, consensusVersionGloas, gotVersion) + // 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) +} + +// 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 gotBlobDataIncluded string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBlobDataIncluded = r.Header.Get("Eth-Blob-Data-Included") + 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.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") +} + +// 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 new file mode 100644 index 0000000000..f76be1a7e7 --- /dev/null +++ b/beacon/goclient/gloas_proposer.go @@ -0,0 +1,208 @@ +package goclient + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// 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. 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" +) + +// 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). 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) { + // 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 +} + +// 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, extraHeaders) + }) +} + +// 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[:])) + + 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 { + return gloasBlockResult{}, err + } + if err := checkGloasConsensusVersion(header); err != nil { + return gloasBlockResult{}, err + } + block, err := decodeGloasBlock(respBody) + if err != nil { + return gloasBlockResult{}, err + } + return gloasBlockResult{block: block}, 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 + } + if err := checkGloasConsensusVersion(header); err != nil { + return gloasBlockResult{}, err + } + block, err := decodeGloasBlock(respBody) + if err != nil { + return gloasBlockResult{}, err + } + 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(consensusVersionHeader); 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{} + 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 +// 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 + } + return err +} + +// 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 *httpStatusError + if !errors.As(err, &httpErr) { + return false + } + body := strings.ToLower(httpErr.body) + return strings.Contains(body, "already known") || strings.Contains(body, "already_known") +} + +// 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 *httpStatusError + return errors.As(err, &httpErr) && (httpErr.status == http.StatusNotFound || httpErr.status == http.StatusMethodNotAllowed) +} + +// 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; 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) + for k, v := range extraHeaders { + merged[k] = v + } + merged[consensusVersionHeader] = consensusVersionGloas + extraHeaders = merged + } + 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 +// 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 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 +} diff --git a/beacon/goclient/gloas_proposer_test.go b/beacon/goclient/gloas_proposer_test.go new file mode 100644 index 0000000000..c3855e58df --- /dev/null +++ b/beacon/goclient/gloas_proposer_test.go @@ -0,0 +1,242 @@ +package goclient + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "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 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, 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") + 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.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.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) +} + +// 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 := gloas.TestingBeaconBlock(7).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, 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: 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) +} + +// 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 + 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}, nil) + 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) +} + +// 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) + _, _ = w.Write([]byte("bad block")) + })) + defer srv.Close() + + _, 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}, nil)) +} + +// 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}, nil)) +} + +func TestIsAlreadyKnown(t *testing.T) { + require.False(t, isAlreadyKnown(nil)) + require.False(t, isAlreadyKnown(errors.New("some other error"))) + 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/goclient.go b/beacon/goclient/goclient.go index a530b7356e..f5788fa166 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 @@ -155,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 @@ -208,6 +220,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 @@ -327,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. @@ -348,7 +373,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/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/beacon/goclient/proposer_preferences.go b/beacon/goclient/proposer_preferences.go new file mode 100644 index 0000000000..6003acf838 --- /dev/null +++ b/beacon/goclient/proposer_preferences.go @@ -0,0 +1,80 @@ +package goclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "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 +// 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) { + // 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 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) { + 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) { + return requestProposerDutiesDependentRoot(ctx, gloasHTTPClient, 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 := jsonDo(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. +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, gloasHTTPClient, gc.clientAddresses[client], 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{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) + } + return err +} diff --git a/beacon/goclient/proposer_preferences_test.go b/beacon/goclient/proposer_preferences_test.go new file mode 100644 index 0000000000..3d3c896474 --- /dev/null +++ b/beacon/goclient/proposer_preferences_test.go @@ -0,0 +1,105 @@ +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)) +} + +// 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} + + 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) +} diff --git a/beacon/goclient/ptc.go b/beacon/goclient/ptc.go new file mode 100644 index 0000000000..25544ed308 --- /dev/null +++ b/beacon/goclient/ptc.go @@ -0,0 +1,208 @@ +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?slot=%d" // slot + payloadAttestationsPath = "/eth/v1/beacon/pool/payload_attestations" + + // 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. +// 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 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, gloasHTTPClient, addr, epoch, validatorIndices) + }) +} + +// 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) + }) +} + +// 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, gloasHTTPClient, gc.clientAddresses[client], messages) + }) +} + +// firstClientResult runs fn against each beacon client in turn, each under its own common-timeout +// 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 + 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(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 + } + 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)) + 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 := jsonDo(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 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 := 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") + } + 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{consensusVersionHeader: consensusVersionGloas} + 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 +// 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) +} + +// 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, 0, fmt.Errorf("new request: %w", err) + } + 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) + } + + resp, err := httpClient.Do(req) + if err != nil { + 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, resp.StatusCode, fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, nil, resp.StatusCode, &httpStatusError{method: method, url: url, status: resp.StatusCode, body: strings.TrimSpace(string(respBody))} + } + 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) + if err != nil { + return err + } + 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..fcaffa1b3a --- /dev/null +++ b/beacon/goclient/ptc_test.go @@ -0,0 +1,112 @@ +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, 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, `{"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", 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, + 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/beacon/goclient/signing.go b/beacon/goclient/signing.go index 3f6f6e6680..b1e872d3d6 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.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 DomainBuilderRequestAuth + // (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/beacon/goclient/spec.go b/beacon/goclient/spec.go index 0f26b32507..bf3fd106c7 100644 --- a/beacon/goclient/spec.go +++ b/beacon/goclient/spec.go @@ -252,7 +252,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 +266,20 @@ 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 + } + } + + 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, @@ -299,6 +316,11 @@ func (gc *GoClient) getForkData(specResponse map[string]any) (map[spec.DataVersi CurrentVersion: fuluForkVersion, Epoch: fuluEpoch, }, + networkconfig.DataVersionGloas: { + PreviousVersion: fuluForkVersion, + CurrentVersion: gloasForkVersion, + Epoch: gloasEpoch, + }, } return forkEpochs, nil diff --git a/beacon/goclient/sync_committee_contribution.go b/beacon/goclient/sync_committee_contribution.go index f2deabbab4..4adeffa35a 100644 --- a/beacon/goclient/sync_committee_contribution.go +++ b/beacon/goclient/sync_committee_contribution.go @@ -51,8 +51,8 @@ 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 { - return nil, DataVersionNil, fmt.Errorf("wait for 1/3 of slot: %w", err) + if err := gc.waitIntoSlot(ctx, slot, 1); err != nil { + 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 @@ -75,8 +75,8 @@ func (gc *GoClient) GetSyncCommitteeContribution( blockRoot := beaconBlockRootResp.Data - if err := gc.waitTwoThirdsIntoSlot(ctx, slot); err != nil { - return nil, DataVersionNil, fmt.Errorf("wait for 2/3 of slot: %w", err) + if err := gc.waitIntoSlot(ctx, slot, 2); err != nil { + return nil, DataVersionNil, fmt.Errorf("wait for contribution deadline: %w", err) } // Fetch sync committee contributions for each subnet in parallel. @@ -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/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/cli/operator/config.go b/cli/operator/config.go index 76024d03ce..2c6baa78fe 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" ) @@ -48,6 +49,8 @@ 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)."` + 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"` @@ -157,6 +160,17 @@ 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) + } + + if err := gloas.ValidateBuilderConfig(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/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..6d72920a8d 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.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, 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 + } + identity, err := resolveOperatorIdentity(ctx, logger, cfg, res) if err != nil { return nil, err @@ -441,6 +448,8 @@ func newNode( valOpts.StorageMap = storageMap 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/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..7013ddf4d5 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -53,6 +53,31 @@ 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 + +# 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). 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 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, 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 +# 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 1e74ae179f..37ce1f7661 100644 --- a/docs/EXTERNAL_BUILDERS.md +++ b/docs/EXTERNAL_BUILDERS.md @@ -1,5 +1,72 @@ # Builder proposals +> **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#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` 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 +`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** + 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 (`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, 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 + 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 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 1. Configure your beacon node to use an external builder diff --git a/exporter/dutytracer/collector.go b/exporter/dutytracer/collector.go index 586fd651c0..9d9096be35 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.IsGloasAtSlot(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, @@ -849,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_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 f607e3a65d..0b5671e922 100644 --- a/exporter/dutytracer/collector_test.go +++ b/exporter/dutytracer/collector_test.go @@ -25,6 +25,8 @@ 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/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" @@ -46,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:]) @@ -381,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:]) @@ -452,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:]) @@ -926,8 +928,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, @@ -936,6 +937,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) { @@ -1086,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:]) @@ -1155,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:]) @@ -1357,6 +1384,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 { @@ -1374,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{ @@ -1418,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, @@ -1483,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:]) @@ -1535,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{ @@ -1574,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{ @@ -1628,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{}{}) @@ -1788,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.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 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 07df3a665b..b22e22b060 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.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 github.com/wealdtech/go-eth2-types/v2 v2.8.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 08b80f8d7d..38a8b2e8e6 100644 --- a/go.sum +++ b/go.sum @@ -731,8 +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.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/ibft/storage/testutils.go b/ibft/storage/testutils.go index e1ef366cf6..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") } @@ -247,6 +251,18 @@ 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) { + 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,29 +325,31 @@ 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) } } +} + +// 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() - // 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) } - // parse mod file return modfile.Parse(modFileName, buf, nil) } diff --git a/message/validation/common_checks.go b/message/validation/common_checks.go index 31059c8528..8b2179670c 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,11 +45,23 @@ 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 switch role { - case spectypes.RoleProposer, ssvtypes.RoleSyncCommitteeContribution: + case spectypes.RoleProposer, spectypes.RoleEnvelopeProposer, spectypes.RolePTCAttester, ssvtypes.RoleSyncCommitteeContribution: ttl = 1 + LateSlotAllowance case spectypes.RoleCommittee, spectypes.RoleAggregatorCommittee, ssvtypes.RoleAggregator: ttl = mv.maxStoredSlots() @@ -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 } @@ -79,8 +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 and self-build envelopes // - else, accept if dutyCount > dutyLimit { e := ErrTooManyDutiesPerEpoch @@ -100,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: @@ -123,6 +154,11 @@ func (mv *messageValidator) dutyLimit(msgID spectypes.MessageID, slot phase0.Slo return min(slotsPerEpoch, 2*validatorIndexCount), true + 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 + default: return 0, false } @@ -136,6 +172,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, @@ -155,6 +197,31 @@ func (mv *messageValidator) validateBeaconDuty( } } + // Rule: For a proposer-preferences message, require a real proposer assignment for the validator at + // 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.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 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.RoleEnvelopeProposer { + validatorIndex := indices[0] + if mv.dutyStore.Proposer.IsEpochSet(epoch) && !mv.dutyStore.Proposer.IsEpochStale(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) @@ -165,6 +232,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/consensus_validation.go b/message/validation/consensus_validation.go index be499112b4..df410946b4 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 and VoluntaryExit) - if role == spectypes.RoleValidatorRegistration || role == spectypes.RoleVoluntaryExit { + // 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 @@ -428,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.RoleEnvelopeProposer: return 2, nil case ssvtypes.RoleSyncCommitteeContribution: return 6, nil @@ -437,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 { @@ -535,17 +537,18 @@ 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-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.RoleEnvelopeProposer { return nil } 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..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) { @@ -140,7 +141,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) }) @@ -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/const.go b/message/validation/const.go index 3c2eb29cc8..77bc038b10 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 @@ -24,6 +26,22 @@ 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 + +// 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 under a new root when the slot's dependent_root changes. Derivation at the +// shared constant. +const maxProposerPreferencesDistinctRoots = gloas.MaxProposerPreferencesDistinctRoots + +// 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 + const ( signatureSize = 256 signatureOffset = 0 diff --git a/message/validation/envelope_proposer_test.go b/message/validation/envelope_proposer_test.go new file mode 100644 index 0000000000..ba99e56a8b --- /dev/null +++ b/message/validation/envelope_proposer_test.go @@ -0,0 +1,87 @@ +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/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). +func TestPartialSignatureTypeMatchesRole_EnvelopeProposer(t *testing.T) { + mv := &messageValidator{} + 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_EnvelopeProposerGloasOnly(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.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_EnvelopeProposer(t *testing.T) { + mv := &messageValidator{} + 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_EnvelopeProposer(t *testing.T) { + mv := &messageValidator{netCfg: networkconfig.TestNetwork} + msgID := ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RoleEnvelopeProposer) + + 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_EnvelopeProposer(t *testing.T) { + mv := &messageValidator{} + 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_EnvelopeProposerAssignmentFreshness(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.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.RoleEnvelopeProposer, slot+1, indices, false)) + ds.Proposer.Set(epoch, assigned) + require.ErrorIs(t, mv.validateBeaconDuty(spectypes.RoleEnvelopeProposer, slot+1, indices, false), ErrNoDuty) +} 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/partial_validation.go b/message/validation/partial_validation.go index 649b031e07..5c69eebdb4 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -121,6 +121,8 @@ func (mv *messageValidator) validatePartialSignatureMessageSemantics( // - SelectionProofPartialSig or PostConsensusPartialSig for Sync committee contribution // - ValidatorRegistrationPartialSig for Validator Registration // - VoluntaryExitPartialSig for Voluntary Exit + // - PTCAttesterPartialSig for PTC attestation + // - ProposerPreferencesPartialSig or RequestAuthPartialSig for Proposer Preferences if !mv.partialSignatureTypeMatchesRole(partialSignatureMessages.Type, role) { return ErrPartialSignatureTypeRoleMismatch } @@ -171,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 @@ -196,6 +199,9 @@ 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 + // - 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 } @@ -279,7 +285,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 @@ -294,6 +300,13 @@ func validatePartialSignatureMessageLimit( e.got = fmt.Sprintf("pre-consensus, having %v", signerState.World.SeenMsgTypes.String()) return e } + case spectypes.ProposerPreferencesPartialSig: + // 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): 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. @@ -316,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, @@ -343,6 +383,19 @@ func (mv *messageValidator) updatePartialSignatureState( return err } + // 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 t := partialSignatureMessages.Type; t { + case spectypes.ProposerPreferencesPartialSig, spectypes.RequestAuthPartialSig: + root := partialSignatureMessages.Messages[0].SigningRoot + 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. + } + return nil } @@ -354,7 +407,10 @@ func (mv *messageValidator) validPartialSigMsgType(msgType spectypes.PartialSigM ssvtypes.ContributionProofs, spectypes.ValidatorRegistrationPartialSig, spectypes.VoluntaryExitPartialSig, - spectypes.AggregatorCommitteePartialSig: + spectypes.AggregatorCommitteePartialSig, + spectypes.PTCAttesterPartialSig, + spectypes.ProposerPreferencesPartialSig, + spectypes.RequestAuthPartialSig: return true default: return false @@ -369,6 +425,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.RoleEnvelopeProposer: + // 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: @@ -377,6 +436,12 @@ 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 + case spectypes.RoleProposerPreferences: + // 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/proposer_preferences_test.go b/message/validation/proposer_preferences_test.go new file mode 100644 index 0000000000..ca707005e8 --- /dev/null +++ b/message/validation/proposer_preferences_test.go @@ -0,0 +1,265 @@ +package validation + +import ( + "errors" + "testing" + "time" + + eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "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" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" +) + +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)) +} + +// 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)) +} + +func TestDutyLimit_ProposerPreferences(t *testing.T) { + mv := &messageValidator{netCfg: networkconfig.TestNetwork} + msgID := ssvtestingutils.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 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) + 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} + // 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)) + // 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 +// 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.Empty(t, s.SeenProposerPreferencesRoots) + require.False(t, s.SeenProposerPreferencesRoots.has(r1)) + + 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.SeenProposerPreferencesRoots.record(r1) + require.Len(t, s.SeenProposerPreferencesRoots, 1) + + 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 +// 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{ + Type: spectypes.ProposerPreferencesPartialSig, + Slot: 1, + Messages: []*spectypes.PartialSignatureMessage{{SigningRoot: root}}, + } + } + record := func(ss *SignerStateForSlotRound, from peer.ID, root [32]byte) { + ss.Peer(from).SeenProposerPreferencesRoots.record(root) + ss.World.SeenProposerPreferencesRoots.record(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 < maxProposerPreferencesDistinctRoots; i++ { + r := root(byte(i + 1)) + require.NoError(t, validatePartialSignatureMessageLimit(ppMsg(r), peerA, ss)) + 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.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(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/ptc_attester_test.go b/message/validation/ptc_attester_test.go new file mode 100644 index 0000000000..c79cd27e29 --- /dev/null +++ b/message/validation/ptc_attester_test.go @@ -0,0 +1,63 @@ +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" + "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 := ssvtestingutils.NewMsgID(spectypes.DomainType{}, make([]byte, 48), spectypes.RolePTCAttester) + + limit, ok := mv.dutyLimit(msgID, 0, nil) + require.True(t, ok) + 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. +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)) +} + +// 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/message/validation/request_auth_test.go b/message/validation/request_auth_test.go new file mode 100644 index 0000000000..79df2a842e --- /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 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{} + r1 := [32]byte{1} + r2 := [32]byte{2} + + require.Empty(t, s.SeenRequestAuthRoots) + require.False(t, s.SeenRequestAuthRoots.has(r1)) + + 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.SeenRequestAuthRoots.record(r1) + require.Len(t, s.SeenRequestAuthRoots, 1) + + 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.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 +// (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).SeenRequestAuthRoots.record(root) + ss.World.SeenRequestAuthRoots.record(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).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.Len(t, ss.World.SeenRequestAuthRoots, 1) + require.Len(t, ss.World.SeenProposerPreferencesRoots, maxProposerPreferencesDistinctRoots) + }) +} + +// 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 117e65ce9a..9dd68014f6 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,12 @@ 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.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/signed_ssv_message.go b/message/validation/signed_ssv_message.go index 6ea2b2984b..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 @@ -153,13 +156,19 @@ 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.IsGloasAtSlot(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: return !isInBooleFork + case spectypes.RolePTCAttester, spectypes.RoleProposerPreferences, spectypes.RoleEnvelopeProposer: + return isInGloas 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..8f302d1af3 --- /dev/null +++ b/message/validation/signed_ssv_message_test.go @@ -0,0 +1,67 @@ +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" + "github.com/ssvlabs/ssv/protocol/v2/types/ssvtestingutils" +) + +// 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: 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) + } + + // Negative control: an out-of-union role still REJECTs at the same gate. + bad := &spectypes.SSVMessage{ + MsgType: spectypes.SSVPartialSignatureMsgType, + MsgID: ssvtestingutils.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) + } + } + } +} diff --git a/message/validation/signer_state.go b/message/validation/signer_state.go index 1f7dd81a27..587430a30e 100644 --- a/message/validation/signer_state.go +++ b/message/validation/signer_state.go @@ -3,9 +3,13 @@ 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" + spectypes "github.com/ssvlabs/ssv-spec/types" ) // SignerStateForSlotRound is a SignerState bundled with some target slot+round. @@ -49,6 +53,8 @@ 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 + s.World.SeenRequestAuthRoots = nil } // SignerState represents the state of a signer (an Operator running a Runner that performs partial-signing for @@ -64,4 +70,40 @@ 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 seenRootSet + + // 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 +} + +// 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 + +func (s seenRootSet) has(root [32]byte) bool { return slices.Contains(s, root) } + +// record adds the root, skipping roots already present. +func (s *seenRootSet) record(root [32]byte) { + if !slices.Contains(*s, root) { + *s = append(*s, root) + } +} + +// 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 + } } 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() +} diff --git a/message/validation/validation_test.go b/message/validation/validation_test.go index 815826faf3..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 @@ -2152,7 +2153,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 @@ -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/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/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/networkconfig/beacon.go b/networkconfig/beacon.go index 230f27e8ad..e6e26d2c0e 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -11,6 +11,12 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ) +// 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 @@ -54,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()) @@ -127,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.IsGloasAtSlot(slot) { + intervalsPerSlot = 4 + } + return b.SlotDuration / time.Duration(intervalsPerSlot) } func (b *Beacon) EpochDuration() time.Duration { @@ -140,6 +157,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, @@ -173,6 +194,28 @@ 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[DataVersionGloas] + 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. +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 new file mode 100644 index 0000000000..dd97e684af --- /dev/null +++ b/networkconfig/beacon_gloas_test.go @@ -0,0 +1,68 @@ +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" +) + +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{ + 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{ + DataVersionGloas: {Epoch: 100}, + }} + require.False(t, scheduled.IsGloas(99)) + 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))) +} + +// 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/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/networkconfig/ssv.go b/networkconfig/ssv.go index 5292e38a07..6fae2f0098 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -25,6 +25,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/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/observability/attributes.go b/observability/attributes.go index 5ad4a42dc2..a1e98bcfee 100644 --- a/observability/attributes.go +++ b/observability/attributes.go @@ -64,6 +64,14 @@ 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 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/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/operator/duties/attester.go b/operator/duties/attester.go index 3043d9420c..df82fd5d7a 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") @@ -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") + // 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 + } } 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") + // 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 + } } 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 fce14e585e..d5e0d8cf0f 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 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{ + { + 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,28 +1123,21 @@ 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() + 1*time.Millisecond) + time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} }() // 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/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/beacon_adapter.go b/operator/duties/beacon_adapter.go index 27a2d95856..49ae429188 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 { @@ -289,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 @@ -320,5 +326,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/dutystore/duties.go b/operator/duties/dutystore/duties.go index 514c3cc48a..1d12688485 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 { @@ -21,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{}), } } @@ -109,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) { @@ -116,6 +124,34 @@ 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. +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) + } + } + 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 +// 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]) + d.stale = make(map[phase0.Epoch]struct{}) } func (d *Duties[D]) IsEpochSet(epoch phase0.Epoch) bool { @@ -125,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 7a9fa8130a..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) @@ -59,6 +89,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/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/operator/duties/proposer.go b/operator/duties/proposer.go index a5ce75421e..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,15 +129,23 @@ 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()) + indicesChangeDeadline := h.netCfg.SlotStartTime(currentSlot).Add(h.netCfg.IntervalDuration(currentSlot)) select { 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. @@ -154,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) @@ -255,17 +289,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") + // 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 + } } span.SetStatus(codes.Ok, "") @@ -283,17 +316,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") + // 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 + } } span.SetStatus(codes.Ok, "") @@ -314,6 +346,9 @@ func (h *ProposerHandler) processExecution(ctx context.Context, epoch phase0.Epo defer span.End() duties := h.duties.CommitteeSlotDuties(epoch, slot) + + h.logProposerSlotDispatch(epoch, slot, duties) + if duties == nil { span.AddEvent("no duties available") span.SetStatus(codes.Ok, "") @@ -338,7 +373,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 +398,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.logNoEligibleValidators(logger, targetEpoch) 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 +415,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)) @@ -396,6 +435,8 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap span.AddEvent("storing duties", trace.WithAttributes(observability.DutyCountAttribute(len(storeDuties)))) h.duties.Set(targetEpoch, storeDuties) + h.logProposerFetchOutcome(logger, targetEpoch, currentSlot, storeDuties) + truncate := -1 if h.exporterMode { truncate = 10 @@ -409,7 +450,109 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, logger *zap ) span.SetStatus(codes.Ok, "") - return nil + return true, nil +} + +// 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() + + selfAttesting := 0 + for _, s := range self { + if s.IsAttesting(targetEpoch) { + selfAttesting++ + } + } + + 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), + ) +} + +// 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 +// 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 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 + } + + // Mirror shouldExecute's window (currentSlot == or +1 == duty.Slot) WITHOUT its warnMisalignedSlotAndDuty + // 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 { + 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", + 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), + ) +} + +// 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 { + if !d.InCommittee { + continue + } + inCommittee++ + if d.Slot < currentSlot { + alreadyPassed = append(alreadyPassed, uint64(d.Slot)) + } + } + + // 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", + 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, hence Debug, not Warn. + if len(alreadyPassed) > 0 { + 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), + zap.Int("already_passed", len(alreadyPassed)), + zap.Uint64s("passed_slots", alreadyPassed), + ) + } } func (h *ProposerHandler) toSpecDuty(duty *eth2apiv1.ProposerDuty, role spectypes.BeaconRole) *spectypes.ValidatorDuty { diff --git a/operator/duties/proposer_preferences.go b/operator/duties/proposer_preferences.go new file mode 100644 index 0000000000..be57ac0721 --- /dev/null +++ b/operator/duties/proposer_preferences.go @@ -0,0 +1,202 @@ +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 + + // 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 + + // 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{}, + } +} + +func (h *ProposerPreferencesHandler) Name() string { + return spectypes.BNRoleProposerPreferences.String() +} + +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. +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() + h.emitForTick(ctx, slot) + + 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 — 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 + // 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 + } + } +} + +// 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). 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 + + 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) + } + 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, recheck) + } +} + +// 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). +func (h *ProposerPreferencesHandler) emitForEpoch(ctx context.Context, epoch phase0.Epoch, currentSlot phase0.Slot, recheck bool) { + if _, done := h.emitted[epoch]; done && !recheck { + return + } + + indices := h.selfParticipatingIndices(epoch) + if len(indices) == 0 { + 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)) + return // retry on the next tick + } + + 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, + Slot: d.Slot, // proposal slot — the self-identifying duty.Slot + ValidatorIndex: d.ValidatorIndex, + }) + } + h.emitted[epoch] = dependentRoot + + if len(preferenceDuties) == 0 { + return + } + + // Emit now: the runner builds, signs, and broadcasts immediately. duty.Slot is the (future) + // 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), + fields.Count(len(preferenceDuties)), + zap.String("dependent_root", dependentRoot.String()), + ) +} + +// evictOutdated drops emitted-epoch markers for epochs before the current one. +func (h *ProposerPreferencesHandler) evictOutdated(currentEpoch phase0.Epoch) { + evictEpochsBefore(h.emitted, currentEpoch) +} diff --git a/operator/duties/proposer_preferences_test.go b/operator/duties/proposer_preferences_test.go new file mode 100644 index 0000000000..2558781645 --- /dev/null +++ b/operator/duties/proposer_preferences_test.go @@ -0,0 +1,346 @@ +package duties + +import ( + "context" + "testing" + "time" + + eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "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" +) + +// 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().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) + + 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, false) + h.emitForEpoch(context.Background(), epoch, currentSlot, false) // cached: must not re-fetch or re-emit + + require.Contains(t, h.emitted, 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), false) + + require.NotContains(t, h.emitted, 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().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) + + 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), false) + + require.Contains(t, h.emitted, epoch) + 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") +} + +// 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() + for _, e := range []phase0.Epoch{4, 5, 6} { + h.emitted[e] = phase0.Root{} + } + + h.evictOutdated(5) + + 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) +// 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} + // 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)). + 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) + + 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) + }) + } +} + +// 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() + 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.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.Len(t, executed, 2) // first emit + changed-root re-emit only +} diff --git a/operator/duties/proposer_test.go b/operator/duties/proposer_test.go index c9447efa23..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" @@ -1046,6 +1049,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 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{ + { + 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,28 +1066,21 @@ 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() + 1*time.Millisecond) + time.Sleep(scheduler.netCfg.IntervalDuration(0) + 1*time.Millisecond) scheduler.indicesChgCh <- struct{}{} }() // 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 +1218,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 +1234,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() @@ -1578,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") +} diff --git a/operator/duties/ptc_attestation.go b/operator/duties/ptc_attestation.go new file mode 100644 index 0000000000..abdf3b59a3 --- /dev/null +++ b/operator/duties/ptc_attestation.go @@ -0,0 +1,177 @@ +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" + "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): 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 *dutystore.Duties[gloas.PTCDuty] + exporterMode bool +} + +func NewPTCAttestationHandler(duties *dutystore.Duties[gloas.PTCDuty], exporterMode bool) *PTCAttestationHandler { + return &PTCAttestationHandler{ + duties: duties, + exporterMode: exporterMode, + } +} + +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.duties.EraseBefore(epoch) + + // 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: + h.invalidateDuties("indices change") + case <-h.reorgEventsCh: + h.invalidateDuties("reorg") + } + } +} + +// 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 +// 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)) + h.duties.Clear() +} + +// 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 h.duties.IsEpochSet(epoch) { + return + } + + 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, eligible) + if err != nil { + h.logger.Warn("failed to fetch PTC duties", fields.Epoch(epoch), zap.Error(err)) + return + } + + 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 { + _, inCommittee := self[d.ValidatorIndex] + storeDuties = append(storeDuties, dutystore.StoreDuty[gloas.PTCDuty]{ + Slot: d.Slot, + ValidatorIndex: d.ValidatorIndex, + Duty: d, + InCommittee: inCommittee, + }) + } + h.duties.Set(epoch, storeDuties) + + h.logger.Debug("fetched PTC duties", fields.Epoch(epoch), zap.Int("duties", len(ptcDuties))) +} + +// 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.PayloadAttestationCutoff(slot) + deadline := h.netCfg.SlotStartTime(slot + 1) + time.AfterFunc(time.Until(executeAt), func() { + h.dutiesExecutor.ExecuteDuties(ctx, duties, deadline) + }) +} + +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 new file mode 100644 index 0000000000..d866561453 --- /dev/null +++ b/operator/duties/ptc_attestation_test.go @@ -0,0 +1,195 @@ +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/operator/duties/dutystore" + "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// 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 + deadlines chan 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) {} + +// 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) + + vp := NewMockValidatorProvider(ctrl) + 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{{ValidatorIndex: idx, Slot: dutySlot}}, nil). + Times(1) + + 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.True(t, store.IsEpochSet(epoch)) + require.NotNil(t, store.ValidatorDuty(epoch, dutySlot, idx)) +} + +// 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.netCfg = networkconfig.TestNetwork + h.validatorProvider = vp + h.beaconNode = bn + + h.fetchDuties(context.Background(), epoch) + + // 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) +} + +// 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 := NewPTCAttestationHandler(store, false) + h.logger = zap.NewNop() + + h.invalidateDuties("test") + + require.False(t, store.IsEpochSet(100)) + require.False(t, store.IsEpochSet(101)) +} + +// 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(dutystore.NewDuties[gloas.PTCDuty](), false) + 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 34d11fac10..f0e8b4bff0 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 @@ -54,7 +55,9 @@ 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 SubmitSyncCommitteeSubscriptions(ctx context.Context, subscription []*eth2apiv1.SyncCommitteeSubscription) error SubscribeToHeadEvents(ctx context.Context, subscriberIdentifier string, ch chan<- *eth2apiv1.HeadEvent) error @@ -171,6 +174,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 { @@ -179,6 +183,7 @@ func NewScheduler(logger *zap.Logger, opts *SchedulerOptions) *Scheduler { NewCommitteeHandler(dutyStore.Attester, dutyStore.SyncCommittee, true), NewValidatorRegistrationHandler(opts.ValidatorRegistrationCh), NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh), + NewProposerPreferencesHandler(), ) } return s @@ -343,7 +348,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. @@ -355,7 +360,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 { @@ -437,10 +442,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. @@ -478,7 +483,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 @@ -560,7 +571,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) }() @@ -620,15 +631,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/scheduler_mock.go b/operator/duties/scheduler_mock.go index 4b38653ae0..307f8952a1 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() @@ -174,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/operator/duties/sync_committee.go b/operator/duties/sync_committee.go index 24f730c8bc..c27a626b3e 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") @@ -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") + // 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 + } } 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") + // 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 + } } 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 bb829c62b8..6493915cd8 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{}{} }() @@ -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() diff --git a/operator/duties/validator_registration.go b/operator/duties/validator_registration.go index 85d4f6f067..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( @@ -161,6 +156,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.IsGloasAtSlot(dutySlot) { + continue + } earliestExecutionSlot := blockSlot + validatorRegistrationExecutionSlotsToPostpone // No de-dup on enqueue: entries are idempotent and bounded. The duty @@ -204,6 +203,13 @@ 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). + // 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 + } + 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/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/operator/validator/controller.go b/operator/validator/controller.go index cfc1e523f0..47bb042eec 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" ) @@ -86,6 +87,8 @@ type ControllerOptions struct { ValidatorSyncer *metadata.Syncer Graffiti []byte ProposerDelay time.Duration + ProposerDelayEPBS time.Duration + Builders gloas.BuilderConfig // worker flags WorkersCount int `yaml:"MsgWorkersCount" env:"MSG_WORKERS_COUNT" env-description:"Number of message processing workers"` @@ -194,23 +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, - ) + 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 @@ -373,6 +377,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: @@ -382,6 +388,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, @@ -790,7 +823,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.BNRoleEnvelopeProposer, + 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) @@ -1066,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[:] } } @@ -1161,6 +1214,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") @@ -1168,10 +1222,13 @@ func SetupRunners( runnersType := []spectypes.RunnerRole{ spectypes.RoleProposer, + spectypes.RoleEnvelopeProposer, ssvtypes.RoleAggregator, ssvtypes.RoleSyncCommitteeContribution, spectypes.RoleValidatorRegistration, spectypes.RoleVoluntaryExit, + spectypes.RolePTCAttester, + spectypes.RoleProposerPreferences, } buildController := func(role spectypes.RunnerRole) *qbftcontroller.Controller { @@ -1205,6 +1262,15 @@ 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() + + // 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 for _, role := range runnersType { @@ -1219,6 +1285,21 @@ func SetupRunners( HighestDecidedSlot: 0, Graffiti: options.Graffiti, ProposerDelay: options.ProposerDelay, + 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 + // 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.NewEnvelopeProposerRunner(runner.EnvelopeProposerRunnerOptions{ + BaseRunnerOptions: baseOpts, + QBFTController: buildController(spectypes.RoleEnvelopeProposer), + ProposedBlockRoots: proposedBlockRoots, + HighestDecidedSlot: 0, }) case ssvtypes.RoleAggregator: // Post-Boole, aggregator duties route through the merged AggregatorCommitteeRunner @@ -1273,6 +1354,18 @@ func SetupRunners( runners[role], err = runner.NewVoluntaryExitRunner(runner.VoluntaryExitRunnerOptions{ BaseRunnerOptions: baseOpts, }) + case spectypes.RolePTCAttester: + 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, + Builders: options.Builders, + RequestAuthCache: requestAuthCache, + }) default: return nil, fmt.Errorf("unexpected duty runner type: %s", role) } 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 d9361e3d3a..d02ef743b9 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" @@ -44,7 +46,9 @@ 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" "github.com/ssvlabs/ssv/storage/basedb" ) @@ -155,6 +159,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") @@ -230,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, } @@ -262,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), }, } @@ -383,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{ @@ -462,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, @@ -1558,7 +1563,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 +1607,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 @@ -1664,3 +1669,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 := 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, + }) + } + + // 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"]) +} 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/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 69bb40538a..f16e563b86 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 @@ -76,6 +78,61 @@ 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 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 +} + +// 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 +// (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), 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. 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). + 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): +// 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) } @@ -125,6 +182,10 @@ type BeaconNode interface { SyncCommitteeContributionCalls ValidatorRegistrationCalls VoluntaryExitCalls + PTCCalls + ProposerPreferencesCalls + GloasProposerCalls + GloasEnvelopeCalls DomainCalls beaconDuties diff --git a/protocol/v2/blockchain/beacon/mock_client.go b/protocol/v2/blockchain/beacon/mock_client.go index ed4399d087..60e0c3b379 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" ) @@ -410,6 +411,248 @@ 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) +} + +// 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() + 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, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao, builderConfig) + ret0, _ := ret[0].(*gloas.BeaconBlock) + 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, 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, builderConfig) +} + +// SubmitGloasBeaconBlock mocks base method. +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, builderURL) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitGloasBeaconBlock indicates an expected call of SubmitGloasBeaconBlock. +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, builderURL) +} + +// 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 @@ -829,6 +1072,37 @@ 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, builderConfig *gloas.ProduceBuilderConfig) (*gloas.BeaconBlock, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGloasBeaconBlock", ctx, slot, graffiti, randao, builderConfig) + ret0, _ := ret[0].(*gloas.BeaconBlock) + 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, 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, builderConfig) +} + // 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() @@ -888,6 +1162,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() @@ -903,6 +1207,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() @@ -973,6 +1292,62 @@ 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() + 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, builderURL string) error { + m.ctrl.T.Helper() + 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, builderURL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitGloasBeaconBlock", reflect.TypeOf((*MockBeaconNode)(nil).SubmitGloasBeaconBlock), ctx, block, builderURL) +} + +// 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() @@ -987,6 +1362,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() 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..5833136371 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)"}, } @@ -122,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/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/qbft/roundtimer/timer.go b/protocol/v2/qbft/roundtimer/timer.go index c01cd77cf5..b70c0e2598 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) @@ -28,16 +32,12 @@ 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, 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 +47,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 +76,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 +131,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 +154,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..fc0d09a4f4 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,20 +208,44 @@ 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 // - 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. @@ -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) } @@ -265,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. @@ -299,7 +322,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 +358,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 +405,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/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/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/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/request_auth_cache.go b/protocol/v2/ssv/request_auth_cache.go new file mode 100644 index 0000000000..1ced643f6c --- /dev/null +++ b/protocol/v2/ssv/request_auth_cache.go @@ -0,0 +1,63 @@ +package ssv + +import ( + "maps" + "sync" + + "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// 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; 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 + // from evicting another's. + currentSlot func() phase0.Slot + + mu sync.Mutex + 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.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.SignedBuilderRequestAuth) { + c.mu.Lock() + defer c.mu.Unlock() + + byBuilder := c.auths[slot] + if byBuilder == nil { + byBuilder = make(map[string]*gloas.SignedBuilderRequestAuth) + c.auths[slot] = byBuilder + } + byBuilder[builderIdentity] = auth + + 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 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.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 new file mode 100644 index 0000000000..f7bf7fc202 --- /dev/null +++ b/protocol/v2/ssv/request_auth_cache_test.go @@ -0,0 +1,42 @@ +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) { + now := phase0.Slot(100) + cache := NewRequestAuthCache(func() phase0.Slot { return now }) + authAt := func(slot phase0.Slot) *gloas.SignedBuilderRequestAuth { + return &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte("x"), Slot: slot}} + } + + require.Empty(t, cache.Get(110)) + + 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(110) + delete(got, "builder-a") + 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/aggregator.go b/protocol/v2/ssv/runner/aggregator.go index 7b7a966986..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) } @@ -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/aggregator_committee.go b/protocol/v2/ssv/runner/aggregator_committee.go index 43faab3f4c..0a391282bb 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 @@ -594,8 +594,8 @@ 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 { + // 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. return err @@ -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 b245171c7c..fcecfe181f 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 { @@ -219,8 +220,20 @@ 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) + // 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; decode into the matching prototype. + decidedPrototype := spectypes.Encoder(&spectypes.BeaconVote{}) + if dutyErr == nil && r.NetworkConfig.IsGloasAtSlot(committeeDuty.DutySlot()) { + decidedPrototype = &gloas.GloasBeaconVote{} + } + span.AddEvent("processing QBFT consensus msg") - decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, msg, &spectypes.BeaconVote{}) + decided, decidedValue, err := r.baseConsensusMsgProcessing(ctx, logger, r.ValCheck.CheckValue, msg, decidedPrototype) if err != nil { return fmt.Errorf("failed processing consensus message: %w", err) } @@ -229,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, @@ -272,7 +284,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 +328,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 @@ -413,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, ), } @@ -452,6 +464,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 +480,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 +1045,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 +1086,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 +1172,34 @@ 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.IsGloasAtSlot(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) + logger.Debug("built gloas attestation vote", + fields.Slot(slot), + zap.Uint64("payload_status_index", uint64(attData.Index))) + } 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 +1218,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 +1226,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/envelope.go b/protocol/v2/ssv/runner/envelope.go new file mode 100644 index 0000000000..f8f73a2f1f --- /dev/null +++ b/protocol/v2/ssv/runner/envelope.go @@ -0,0 +1,374 @@ +package runner + +import ( + "bytes" + "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/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" + "github.com/ssvlabs/ssv/protocol/v2/ssv" + ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" +) + +// 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 EnvelopeProposerRunner 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 + + // 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 +} + +// EnvelopeProposerRunnerOptions bundles the dependencies required by NewEnvelopeProposerRunner. +type EnvelopeProposerRunnerOptions struct { + BaseRunnerOptions + + QBFTController *controller.Controller + ProposedBlockRoots *ssv.ProposedBlockRoots + HighestDecidedSlot phase0.Slot +} + +func NewEnvelopeProposerRunner(opts EnvelopeProposerRunnerOptions) (Runner, error) { + if len(opts.Share) != 1 { + return nil, errors.New("must have one share") + } + + return &EnvelopeProposerRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleEnvelopeProposer, + 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 *EnvelopeProposerRunner) 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 *EnvelopeProposerRunner) ProcessPreConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return errors.New("no pre-consensus phase for envelope proposer") +} + +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) + + 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.RoleEnvelopeProposer) + + 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 *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) + + 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.RoleEnvelopeProposer) + + // 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 whose cached envelope +// 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 *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.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.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)) + } + + 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 *EnvelopeProposerRunner) 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 *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 + + 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) + } + logger.Debug("built execution payload envelope", fields.Slot(slot)) + + 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, caches the full +// envelope for the later content-matched publish, and wraps its blinded form as the QBFT value. +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) + } + 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. +func (r *EnvelopeProposerRunner) expectedPreConsensusRootsAndDomain() ([]ssz.HashRoot, phase0.DomainType, error) { + return nil, spectypes.DomainError, errors.New("no pre-consensus phase for envelope proposer") +} + +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) + } + 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 *EnvelopeProposerRunner) GetNetwork() protocolp2p.Network { + return r.network +} + +func (r *EnvelopeProposerRunner) GetBeaconNode() beacon.BeaconNode { + return r.beacon +} + +func (r *EnvelopeProposerRunner) GetShare() *spectypes.Share { + for _, share := range r.Share { + return share + } + return nil +} + +func (r *EnvelopeProposerRunner) GetSigner() ekm.BeaconSigner { + return r.signer +} + +func (r *EnvelopeProposerRunner) GetOperatorSigner() ssvtypes.OperatorSigner { + return r.operatorSigner +} + +func (r *EnvelopeProposerRunner) MarshalJSON() ([]byte, error) { + return marshalRunnerStateJSON(r.BaseRunner) +} + +func (r *EnvelopeProposerRunner) UnmarshalJSON(data []byte) error { + br, err := unmarshalRunnerStateJSON(data) + if err != nil { + return err + } + r.BaseRunner = br + r.ValCheck = nil + return nil +} + +func (r *EnvelopeProposerRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *EnvelopeProposerRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *EnvelopeProposerRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + 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 new file mode 100644 index 0000000000..325b615430 --- /dev/null +++ b/protocol/v2/ssv/runner/envelope_e2e_test.go @@ -0,0 +1,215 @@ +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/protocol/v2/types/ssvtestingutils" + "github.com/ssvlabs/ssv/ssvsigner/ekm" +) + +func envelopeDuty(slot phase0.Slot) *spectypes.ValidatorDuty { + return &spectypes.ValidatorDuty{ + Type: spectypes.BNRoleEnvelopeProposer, + 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 newEnvelopeProposerRunnerForTest(t *testing.T, bn beacon.BeaconNode) (*EnvelopeProposerRunner, *spectestingutils.TestKeySet) { + t.Helper() + + cfg := cloneTestNetworkConfig() + keySet := spectestingutils.Testing4SharesSet() + share := spectestingutils.TestingShare(keySet, spectestingutils.TestingValidatorIndex) + 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) + 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 := NewEnvelopeProposerRunner(EnvelopeProposerRunnerOptions{ + 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.(*EnvelopeProposerRunner) + 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 *EnvelopeProposerRunner, 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 := 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 + 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 TestEnvelopeProposerRunner_SubmitEnvelopeProposerPublishes(t *testing.T) { + const slot = phase0.Slot(8) + envelope := sampleEnvelope() + cd := decidedEnvelopeConsensusData(t, slot, envelope) + + bn := newEnvelopeTestBeacon() + runner, keySet := newEnvelopeProposerRunnerForTest(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 TestEnvelopeProposerRunner_SubmitEnvelopeNonBuilderSkips(t *testing.T) { + const slot = phase0.Slot(8) + cd := decidedEnvelopeConsensusData(t, slot, sampleEnvelope()) + + bn := newEnvelopeTestBeacon() + 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 + // 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 *EnvelopeProposerRunner, 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 TestEnvelopeProposerRunner_ProcessPostConsensusReconstructsAndPublishes(t *testing.T) { + const slot = phase0.Slot(8) + envelope := sampleEnvelope() + cd := decidedEnvelopeConsensusData(t, slot, envelope) + + bn := newEnvelopeTestBeacon() + runner, keySet := newEnvelopeProposerRunnerForTest(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) +} diff --git a/protocol/v2/ssv/runner/envelope_test.go b/protocol/v2/ssv/runner/envelope_test.go new file mode 100644 index 0000000000..f1b4d44baa --- /dev/null +++ b/protocol/v2/ssv/runner/envelope_test.go @@ -0,0 +1,147 @@ +package runner + +import ( + "context" + "testing" + + "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/blockchain/beacon" + "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: &gloas.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.BNRoleEnvelopeProposer, Slot: slot, ValidatorIndex: 3}, + DataSSZ: dataSSZ, + } + encoded, err := cd.Encode() + require.NoError(t, err) + return blinded, encoded +} + +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 TestEnvelopeProposerRunner_ExpectedPostConsensusRootsAndDomain(t *testing.T) { + blinded, encoded := envelopeConsensusDataSSZ(t, 5, phase0.Root{0xaa}) + r := &EnvelopeProposerRunner{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 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 TestEnvelopeProposerRunner_ExecuteDutyRequiresDecidedRoot(t *testing.T) { + r := &EnvelopeProposerRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleEnvelopeProposer, + Share: map[phase0.ValidatorIndex]*spectypes.Share{ + 3: {ValidatorIndex: 3, ValidatorPubKey: spectypes.ValidatorPK{0x42}}, + }, + }, + measurements: newMeasurementsStore(), + proposedBlockRoots: ssv.NewProposedBlockRoots(), + } + duty := &spectypes.ValidatorDuty{Type: spectypes.BNRoleEnvelopeProposer, Slot: 5, ValidatorIndex: 3} + + require.ErrorContains(t, r.executeDuty(context.Background(), zap.NewNop(), duty), "no decided block root") +} + +type envelopeTestBeacon struct { + beacon.BeaconNode + envelope *gloas.ExecutionPayloadEnvelope + submitted []*gloas.SignedExecutionPayloadEnvelope +} + +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: &gloas.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 TestEnvelopeProposerRunner_ProduceBlindedEnvelope(t *testing.T) { + envelope := sampleEnvelope() + 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) + 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 TestEnvelopeProposerRunner_BuiltDecidedEnvelope(t *testing.T) { + envelope := sampleEnvelope() + blinded, err := envelope.Blinded() + require.NoError(t, err) + decided, err := blinded.Encode() + require.NoError(t, err) + + 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 + + r.cachedEnvelope = nil + require.False(t, r.builtDecidedEnvelope(decided)) // nothing cached (e.g. after a round change) +} diff --git a/protocol/v2/ssv/runner/observability.go b/protocol/v2/ssv/runner/observability.go index 22562597eb..e42f417121 100644 --- a/protocol/v2/ssv/runner/observability.go +++ b/protocol/v2/ssv/runner/observability.go @@ -124,6 +124,36 @@ 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)"))) + + 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"))) + + requestAuthReconstructionCounter = metrics.New( + meter.Int64Counter( + 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"))) + + builderPreferencesSubmitCounter = metrics.New( + meter.Int64Counter( + observability.InstrumentName(observabilityNamespace, "builder_preferences.submits"), + metric.WithUnit("{submit}"), + 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) { @@ -142,6 +172,70 @@ func recordDutyOutcome(ctx context.Context, role spectypes.RunnerRole, outcome d )) } +// 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 ( + // 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 +// 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))) +} + +// recordRequestAuthReconstruction counts a threshold-reconstructed request-auth signing root +// (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)) +} + +// 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 { + 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/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 de47971677..ea784cf9bc 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 { @@ -47,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 @@ -58,6 +61,29 @@ 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 + + // 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. + // 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, resolved once at construction (issue #2962, phase 2): + // the produceBlockV4 POST body is assembled from it plus the per-slot reconstructed auths. Not + // 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. + 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. @@ -71,8 +97,22 @@ 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). + ProposedBlockRoots *ssv.ProposedBlockRoots + + // 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) { @@ -80,6 +120,13 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { return nil, errors.New("must have one share") } + // 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) + } + return &ProposerRunner{ BaseRunner: &BaseRunner{ RunnerRoleType: spectypes.RoleProposer, @@ -98,7 +145,12 @@ func NewProposerRunner(opts ProposerRunnerOptions) (Runner, error) { measurements: newMeasurementsStore(), graffiti: opts.Graffiti, - proposerDelay: opts.ProposerDelay, + proposerDelay: opts.ProposerDelay, + proposerDelayEPBS: opts.ProposerDelayEPBS, + proposedBlockRoots: opts.ProposedBlockRoots, + startEnvelopeDuty: opts.StartEnvelopeDuty, + builders: builders, + requestAuthCache: opts.RequestAuthCache, }, nil } @@ -164,7 +216,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) @@ -176,63 +228,132 @@ 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.IsGloasAtSlot(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 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() + builderConfig := r.gloasBuilderConfig(ctx, duty.Slot) + block, builderURL, err := r.GetBeaconNode().GetGloasBeaconBlock(ctx, duty.Slot, r.graffiti, randaoReveal, builderConfig) if err != nil { - return fmt.Errorf("failed to blind full block: %w", err) + 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 := 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) } - // 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.proposerDelayForSlot(duty.Slot)), + 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, - } + }, nil +} - r.measurements.StartConsensus() - if err := r.decide(ctx, logger, duty.Slot, input, r.ValCheck); err != nil { - return fmt.Errorf("qbft-decide: %w", err) +// 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 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 } - - return nil + 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) + } + return &cfg } func (r *ProposerRunner) ProcessConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.SignedSSVMessage) error { @@ -259,15 +380,30 @@ 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.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) + if decErr != nil { + 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 { - 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() @@ -299,34 +435,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" @@ -394,6 +505,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.IsGloasAtSlot(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,14 +529,22 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L } loggerFields, proposalTraceAttrs := proposalCommonFields(vBlk) - logger = logger.With(loggerFields...) 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) +} + +// 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 +556,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 +575,93 @@ func (r *ProposerRunner) ProcessPostConsensus(ctx context.Context, logger *zap.L return nil } +// 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, 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 { + 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} + 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)) + finishErr = fmt.Errorf("%s: %w", errMsg, err) + } else { + recordProposalBuildSource(ctx, gloasBuildSource(block)) + finishErr = r.finishSubmittedProposal(ctx, logger, span, start, nil) + } + + 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). 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 || !selfBuild(block) { + return + } + 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 { + bid := block.Body.SignedExecutionPayloadBid + 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 { + if r.proposedBlockRoots == nil { + return 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) { currentDutySlot, err := r.currentDutySlot() if err != nil { @@ -468,9 +679,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.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) + } + 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 } @@ -525,16 +746,26 @@ 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) } 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 } @@ -566,37 +797,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/proposer_preferences.go b/protocol/v2/ssv/runner/proposer_preferences.go new file mode 100644 index 0000000000..8931a0163a --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences.go @@ -0,0 +1,642 @@ +package runner + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" + "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" + "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" +) + +var _ Runner = (*ProposerPreferencesRunner)(nil) + +// 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. +// +// 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 + + opts ProposerPreferencesRunnerOptions + + // 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 + // 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 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. +type ProposerPreferencesRunnerOptions struct { + BaseRunnerOptions + + FeeRecipientProvider feeRecipientProvider + GasLimit uint64 + + // 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 +} + +func NewProposerPreferencesRunner(opts ProposerPreferencesRunnerOptions) (Runner, error) { + if len(opts.Share) != 1 { + return nil, fmt.Errorf("must have one share") + } + + // 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) + } + + return &ProposerPreferencesRunner{ + BaseRunner: &BaseRunner{ + RunnerRoleType: spectypes.RoleProposerPreferences, + NetworkConfig: opts.NetworkConfig, + Share: opts.Share, + }, + opts: opts, + builders: resolved.Entries, + bySlot: map[phase0.Slot]*proposerPreferencesSlotRunner{}, + pending: map[phase0.Slot][]*spectypes.PartialSignatureMessages{}, + }, 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, 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.builders) + if prev, ok := r.bySlot[slot]; ok { + sub.submittedPreferences = prev.submittedPreferences + sub.broadcastPreferences = prev.broadcastPreferences + // 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() { + 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-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. + // + // 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", + 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-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] + if !ok { + // 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-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-role partials (preference and request-auth alike) carry exactly one message + } + 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") +} + +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 (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 { + if slot < current { + 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 } + +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, 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 { + 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{} + } + if r.pending == nil { + r.pending = map[phase0.Slot][]*spectypes.PartialSignatureMessages{} + } + 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 + + // 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 + + // 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 + + // 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; + // 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 + // 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: + // 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 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. + // + // 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{} +} + +func newProposerPreferencesSlotRunner(opts ProposerPreferencesRunnerOptions, builders []gloas.ResolvedBuilderEntry) *proposerPreferencesSlotRunner { + return &proposerPreferencesSlotRunner{ + 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, + builders: builders, + requestAuthCache: opts.RequestAuthCache, + broadcastAuthRoots: map[[32]byte]struct{}{}, + reconstructedAuthRoots: map[[32]byte]struct{}{}, + } +} + +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 + } + // 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). + 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 { + 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.submittedPreferences = r.proposerPreferences + r.markDutySucceeded() + logger.Info("✔️ successfully submitted proposer preferences", fields.Slot(r.proposerPreferences.ProposalSlot)) + return nil +} + +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 *proposerPreferencesSlotRunner) ProcessPostConsensus(ctx context.Context, logger *zap.Logger, signedMsg *spectypes.PartialSignatureMessages) error { + return fmt.Errorf("no post-consensus phase for proposer preferences") +} + +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 *proposerPreferencesSlotRunner) expectedPostConsensusRootsAndDomain(context.Context) ([]ssz.HashRoot, phase0.DomainType, error) { + 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 { + validatorDuty, err := validatorDutyFromDuty(duty) + if err != nil { + return err + } + 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 can skip it; it never fails the duty. + 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); + // 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 + } + + 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. + 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)) + + 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) + } + + 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) + } + r.broadcastPreferences = preferences + 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 *proposerPreferencesSlotRunner) 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) + } + + // 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 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, + ValidatorIndex: r.GetShare().ValidatorIndex, + FeeRecipient: feeRecipient, + TargetGasLimit: gasLimit, + }, nil +} + +func (r *proposerPreferencesSlotRunner) GetNetwork() protocolp2p.Network { return r.network } + +func (r *proposerPreferencesSlotRunner) GetBeaconNode() beacon.BeaconNode { return r.beacon } + +func (r *proposerPreferencesSlotRunner) GetSigner() ekm.BeaconSigner { return r.signer } + +func (r *proposerPreferencesSlotRunner) GetOperatorSigner() ssvtypes.OperatorSigner { + return r.operatorSigner +} + +// Only BaseRunner is persisted; the frozen observation is transient per-duty state. +func (r *proposerPreferencesSlotRunner) MarshalJSON() ([]byte, error) { + type proposerPreferencesSlotRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + return json.Marshal(&proposerPreferencesSlotRunnerJSON{BaseRunner: r.BaseRunner}) +} + +func (r *proposerPreferencesSlotRunner) UnmarshalJSON(data []byte) error { + type proposerPreferencesSlotRunnerJSON struct { + BaseRunner *BaseRunner `json:"BaseRunner"` + } + aux := &proposerPreferencesSlotRunnerJSON{} + 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 *proposerPreferencesSlotRunner) Encode() ([]byte, error) { + return json.Marshal(r) +} + +func (r *proposerPreferencesSlotRunner) Decode(data []byte) error { + return json.Unmarshal(data, r) +} + +func (r *proposerPreferencesSlotRunner) GetRoot() ([32]byte, error) { + marshaledRoot, err := r.Encode() + if err != nil { + 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_request_auth.go b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go new file mode 100644 index 0000000000..05d9924bdc --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences_request_auth.go @@ -0,0 +1,196 @@ +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 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 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.BuilderRequestAuth + 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 // 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, +// 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 + } + + // 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{} + 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] + 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: 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) + 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.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)) + 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 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 { + 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{}{} + 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 { + 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. 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)) + 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 new file mode 100644 index 0000000000..84ce97a488 --- /dev/null +++ b/protocol/v2/ssv/runner/proposer_preferences_test.go @@ -0,0 +1,306 @@ +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" + 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{} + +func (errFeeRecipientProvider) GetFeeRecipient(spectypes.ValidatorPK) (bellatrix.ExecutionAddress, error) { + 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 + submittedBuilderPrefs [][]*gloas.BuilderPreferencesEntry +} + +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 (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) + + r, err := NewProposerPreferencesRunner(ProposerPreferencesRunnerOptions{ + BaseRunnerOptions: BaseRunnerOptions{ + Share: map[phase0.ValidatorIndex]*spectypes.Share{0: {}}, + }, + }) + require.NoError(t, err) + require.Equal(t, spectypes.RoleProposerPreferences, r.(*ProposerPreferencesRunner).RunnerRoleType) +} + +// 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{ + 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) + + // 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 + 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/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. +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.builders) + disp.bySlot[current+10] = newProposerPreferencesSlotRunner(disp.opts, disp.builders) + + 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)) +} + +// 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 2*maxPendingRootsPerSigner + 8 { // 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") + + // 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))) + } + 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{} + 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 TestProposerPreferencesSlotRunner_ExpectedPreConsensusRootsAndDomain(t *testing.T) { + r := &proposerPreferencesSlotRunner{} + + _, _, 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) +} diff --git a/protocol/v2/ssv/runner/proposer_test.go b/protocol/v2/ssv/runner/proposer_test.go index 5546d88c6c..9bcdbfa04c 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" @@ -14,6 +15,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 +25,8 @@ 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/protocol/v2/types/ssvtestingutils" "github.com/ssvlabs/ssv/ssvsigner/ekm" ) @@ -38,6 +42,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 +68,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.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 +} + +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 @@ -233,6 +273,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() @@ -297,7 +354,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 +377,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 +398,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 +411,73 @@ 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, + } +} + +// 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) + 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) + + 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) +} + +// 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() + + 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, slot, beacon.lastGetSlot) + require.Equal(t, []byte("graffiti"), beacon.lastGetGraffiti) + require.Equal(t, []byte("randao"), beacon.lastGetRandao) +} + func newProposerRunnerForTest( t *testing.T, beacon *proposerTestBeacon, @@ -370,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) @@ -429,12 +553,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() @@ -445,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 @@ -527,3 +651,95 @@ 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) + + var gotSlot phase0.Slot + called := false + 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) + 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) + + called := false + 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) + 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) { + 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) +} diff --git a/protocol/v2/ssv/runner/ptc_attester.go b/protocol/v2/ssv/runner/ptc_attester.go new file mode 100644 index 0000000000..884dd2dcc7 --- /dev/null +++ b/protocol/v2/ssv/runner/ptc_attester.go @@ -0,0 +1,245 @@ +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 + } + // 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) +} + +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 concluded 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 + } + + // 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") + } + + // 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 { + 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 +} + +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, 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 { + 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 { + // 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 + } + // 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 + } + + // 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/ssv/runner/ptc_attester_test.go b/protocol/v2/ssv/runner/ptc_attester_test.go new file mode 100644 index 0000000000..77d61c5b12 --- /dev/null +++ b/protocol/v2/ssv/runner/ptc_attester_test.go @@ -0,0 +1,83 @@ +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/mock/gomock" + "go.uber.org/zap" + + "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" + "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).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)) +} + +// 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") + }) + } +} 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..a680651f8b --- /dev/null +++ b/protocol/v2/ssv/runner/request_auth_test.go @@ -0,0 +1,435 @@ +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 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. +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(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 + } + require.NoError(t, gloas.ValidateBuilderConfig(gloas.BuilderConfig{Entries: 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: gloas.BuilderConfig{Entries: 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.BuilderRequestAuth{Data: data, Slot: proposalSlot} + 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) + 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 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 distinct root; the token-sharing pair broadcasts once") + + 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) + 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) + + // 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(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") + + // 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. + 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) + 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) + 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)) +} + +// 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.BuilderRequestAuth{Data: []byte("https://builder.example.com"), Slot: proposalSlot} + 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) + 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). +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(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: gloas.BuilderConfig{Entries: 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.BuilderRequestAuth{Data: authData, Slot: proposalSlot} + 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) + 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(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: gloas.BuilderConfig{Entries: 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.BuilderRequestAuth{Data: []byte("https://builder-a.example.com"), Slot: proposalSlot} + 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) + 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/ssv/runner/runner.go b/protocol/v2/ssv/runner/runner.go index fb82aedd0b..f1410abdff 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() } @@ -236,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 { @@ -281,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 @@ -291,29 +321,54 @@ 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 // 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 -// executes at blockSlot+12); for beacon duties the two coincide. +// 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. 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 + } + } + + // 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) - 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 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))) } } @@ -329,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}) } } }() @@ -342,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. @@ -351,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) @@ -383,6 +438,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 spectypes.ValidatorPK, + msgs *spectypes.PartialSignatureMessages, +) error { + domain := b.NetworkConfig.DomainTypeAtSlot(msgs.Slot) + 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) + } + + 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. diff --git a/protocol/v2/ssv/runner/runner_deadline_test.go b/protocol/v2/ssv/runner/runner_deadline_test.go index 8de1791ffc..7116c1911f 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" @@ -21,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 @@ -48,6 +50,53 @@ 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("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/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() diff --git a/protocol/v2/ssv/runner/runner_validations.go b/protocol/v2/ssv/runner/runner_validations.go index 92236245c4..fed8cc5cce 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{} - 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() + + // 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.IsGloasAtSlot(expectedSlot) { + decidedValue = &gloas.GloasBeaconVote{} + } + if err := decidedValue.Decode(decidedValueBytes); err != nil { + return fmt.Errorf("failed to parse decided beacon vote: %w", err) + } + return b.validatePartialSigMsg(psigMsgs, expectedSlot) } } diff --git a/protocol/v2/ssv/runner/sync_committee_contribution.go b/protocol/v2/ssv/runner/sync_committee_contribution.go index 050d8c0798..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) } @@ -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 } 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/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") } diff --git a/protocol/v2/ssv/runner/validator_registration.go b/protocol/v2/ssv/runner/validator_registration.go index a472afddc8..04e4f4648a 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 { @@ -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) } @@ -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. 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.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 diff --git a/protocol/v2/ssv/validator/committee_observer_test.go b/protocol/v2/ssv/validator/committee_observer_test.go index bff005d6b7..a1891bafc5 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" @@ -13,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" ) @@ -35,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{ @@ -72,3 +77,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()) +} 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/opts.go b/protocol/v2/ssv/validator/opts.go index a38d49cc1c..d4f9117a34 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 @@ -51,49 +52,18 @@ type CommonOptions struct { MessageValidator validation.MessageValidator Graffiti []byte ProposerDelay time.Duration + ProposerDelayEPBS time.Duration + Builders gloas.BuilderConfig } -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, -) *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, +// 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( 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/ssv/value_check.go b/protocol/v2/ssv/value_check.go index 9f8f84ee72..225261fa76 100644 --- a/protocol/v2/ssv/value_check.go +++ b/protocol/v2/ssv/value_check.go @@ -12,6 +12,7 @@ import ( "github.com/ssvlabs/ssv/networkconfig" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" + "github.com/ssvlabs/ssv/protocol/v2/types/gloas" ) type ValueChecker interface { @@ -78,6 +79,141 @@ func (v *voteChecker) CheckValue(value []byte) error { return nil } +type gloasVoteChecker struct { + signer ekm.BeaconSigner + slot phase0.Slot + sharePublicKeys []phase0.BLSPubKey + expectedVote *gloas.GloasBeaconVote +} + +// NewGloasVoteChecker validates the committee runner's consensus value on Gloas-and-later slots +// (SIP #94 §2). It mirrors NewVoteChecker — slashing protection plus epoch-only majority-fork +// protection — and adds the one Gloas rule: AttestationDataIndex, the BN-supplied payload-status +// index, must be 0 or 1. That index is trusted from the QBFT leader, not compared against the +// operator's own view, exactly as the runner already trusts the leader's block root. +func NewGloasVoteChecker( + signer ekm.BeaconSigner, + slot phase0.Slot, + sharePublicKeys []phase0.BLSPubKey, + expectedVote *gloas.GloasBeaconVote, +) ValueChecker { + return &gloasVoteChecker{ + signer: signer, + slot: slot, + sharePublicKeys: sharePublicKeys, + expectedVote: expectedVote, + } +} + +func (v *gloasVoteChecker) CheckValue(value []byte) error { + bv := gloas.GloasBeaconVote{} + if err := bv.Decode(value); err != nil { + return spectypes.WrapError(spectypes.DecodeBeaconVoteErrorCode, fmt.Errorf("failed decoding gloas beacon vote: %w", err)) + } + + if bv.Source.Epoch >= 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 — 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, + 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 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 != 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 { @@ -170,19 +306,25 @@ 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 } - 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 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 { + 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) } @@ -206,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 } @@ -229,40 +371,69 @@ 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) } - if err := ssvtypes.ValidateConsensusData(cd); err != nil { - return cd, spectypes.NewError(spectypes.QBFTValueInvalidErrorCode, "invalid value") + + 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) + 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, 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 ff512dff7c..05660e174b 100644 --- a/protocol/v2/ssv/value_check_test.go +++ b/protocol/v2/ssv/value_check_test.go @@ -1,11 +1,16 @@ 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/networkconfig" + "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 +111,241 @@ 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 (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}, + 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 +} + +// --- 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}))) +} + +// 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") +} + +// 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} + +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}, + ExecutionRequests: &gloas.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.BNRoleEnvelopeProposer, + 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, 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}, 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}, 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, gloas.BuilderIndexSelfBuild))) +} + +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/protocol/v2/types/gloas/beacon_block.go b/protocol/v2/types/gloas/beacon_block.go new file mode 100644 index 0000000000..9eeae3181c --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block.go @@ -0,0 +1,83 @@ +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,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 +// 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, +// 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 + 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"` + // 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. +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) } + +// 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) } + +// 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/beacon_block_encoding.go b/protocol/v2/types/gloas/beacon_block_encoding.go new file mode 100644 index 0000000000..f555aafb27 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block_encoding.go @@ -0,0 +1,965 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: d0f3e7c62e3866c9a5addda7dc6eca6ce4403294180e85119360901d134119a9 +// 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(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(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..40335bf506 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_block_test.go @@ -0,0 +1,47 @@ +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/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: &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/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/beacon_vote.go b/protocol/v2/types/gloas/beacon_vote.go new file mode 100644 index 0000000000..8ef11af848 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_vote.go @@ -0,0 +1,31 @@ +package gloas + +import ( + "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 ./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..97d6f42227 --- /dev/null +++ b/protocol/v2/types/gloas/beacon_vote_encoding.go @@ -0,0 +1,122 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 4080ac6d77c7ac29416f1dabf14c44f39cc124657e58115d522219c7e27be5f0 +// 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/builder_entry.go b/protocol/v2/types/gloas/builder_entry.go new file mode 100644 index 0000000000..659409d970 --- /dev/null +++ b/protocol/v2/types/gloas/builder_entry.go @@ -0,0 +1,252 @@ +package gloas + +import ( + "encoding/hex" + "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 +// 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 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#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) +} + +// 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. +// +// 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. +// +// 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. + 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. + // Required and non-empty. + URL string `yaml:"URL"` + // 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"` + // 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"` + // 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"` +} + +// 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 +} + +// Configured reports whether the operator set any direct-builder configuration — entries or the top-level +// 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 +} + +// 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 + } + b, err := hex.DecodeString(strings.TrimPrefix(e.AuthData, "0x")) + if err != nil { + return nil, fmt.Errorf("invalid AuthData hex: %w", err) + } + if len(b) > MaxBuilderAuthDataSize { + return nil, fmt.Errorf("AuthData is %d bytes, exceeding the %d limit", len(b), MaxBuilderAuthDataSize) + } + return b, nil +} + +// 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 cfg.MinBid +} + +// 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 + } + return cfg.EffectiveBoostFactor() +} + +// 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. 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 + 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 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) + } + 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 ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: invalid URL: %w", i, err) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + 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 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 ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: %w", i, err) + } + if len(data) == 0 { + 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 ResolvedBuilderConfig{}, fmt.Errorf("builder entry %d: duplicate (URL, AuthData) identity", i) + } + seen[identity] = struct{}{} + 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 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 new file mode 100644 index 0000000000..852b2375fd --- /dev/null +++ b/protocol/v2/types/gloas/builder_entry_test.go @@ -0,0 +1,140 @@ +package gloas + +import ( + "encoding/hex" + "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) + + _, 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", MaxBuilderAuthDataSize+1)}).AuthDataBytes() + require.ErrorContains(t, err, "exceeding") +} + +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 TestBuilderConfig_Configured(t *testing.T) { + 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) + 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}) + } + + 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, + ValidateBuilderConfig(BuilderConfig{Entries: make([]BuilderEntry, MaxBuilderEntries+1)}), + "exceed") + // 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, 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", 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/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), + }) +} 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/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..01a295301e --- /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.BNRoleEnvelopeProposer, + 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.go b/protocol/v2/types/gloas/execution_payload.go new file mode 100644 index 0000000000..885cc72b84 --- /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'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). +// +// 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"` + 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_bid.go b/protocol/v2/types/gloas/execution_payload_bid.go new file mode 100644 index 0000000000..705add70ef --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_bid.go @@ -0,0 +1,44 @@ +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: 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"` + BlockHash phase0.Hash32 `ssz-size:"32"` + PrevRandao phase0.Hash32 `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..e59f629311 --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_bid_encoding.go @@ -0,0 +1,310 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 96ab5c5b5fa31abaf95dfca1088f2821793e0442ab69d4fef607a3c534b58289 +// 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) +} 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 new file mode 100644 index 0000000000..402769395e --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope.go @@ -0,0 +1,73 @@ +package gloas + +import ( + "fmt" + + "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 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,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 +// 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. +// +// 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). + ExecutionRequests *ExecutionRequests + BuilderIndex BuilderIndex + 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) } + +// 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 *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 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"` +} + +// 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 new file mode 100644 index 0000000000..ebbbe480bd --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope_encoding.go @@ -0,0 +1,389 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: d0f3e7c62e3866c9a5addda7dc6eca6ce4403294180e85119360901d134119a9 +// Version: 0.1.3 +package gloas + +import ( + 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, uint64(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 = 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(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(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(uint64(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) +} + +// 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(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(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 new file mode 100644 index 0000000000..03b58150dd --- /dev/null +++ b/protocol/v2/types/gloas/execution_payload_envelope_test.go @@ -0,0 +1,118 @@ +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/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: &ExecutionRequests{}, + BuilderIndex: 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, 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) +} + +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: &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: &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") +} 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[:])) +} 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/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 new file mode 100644 index 0000000000..33a8d1aaa9 --- /dev/null +++ b/protocol/v2/types/gloas/payload_attestation.go @@ -0,0 +1,107 @@ +package gloas + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + + "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 +// execution payload for BeaconBlockRoot at Slot was present, and its blobs available. +// Signed under DomainPTCAttester with domain epoch = epoch(Slot). 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) + } + if err := decodeHexInto(p.BeaconBlockRoot[:], data.BeaconBlockRoot, "beacon block root"); err != nil { + return err + } + 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 + if err := decodeHexInto(p.Signature[:], data.Signature, "signature"); err != nil { + return err + } + 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..f739f104ff --- /dev/null +++ b/protocol/v2/types/gloas/payload_attestation_encoding.go @@ -0,0 +1,181 @@ +// Code generated by fastssz. DO NOT EDIT. +// Hash: 8e151b578e222df02ceda6786c1d1357c4b312a67ce83d18eeaec20d03ea7714 +// 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/produce_builder_config.go b/protocol/v2/types/gloas/produce_builder_config.go new file mode 100644 index 0000000000..959d8b8329 --- /dev/null +++ b/protocol/v2/types/gloas/produce_builder_config.go @@ -0,0 +1,109 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "strconv" + + "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, + }) +} + +// 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.BoostFactor, + } + authUnavailable := 0 + for i := range cfg.Entries { + e := &cfg.Entries[i] + auth, ok := auths[e.Identity] + if !ok { + authUnavailable++ + continue + } + out.Builders = append(out.Builders, ProduceBuilderEntry{ + URL: e.URL, + Auth: auth, + BuilderPubKeys: e.BuilderPubKeys, + MaxExecutionPayment: e.MaxExecutionPayment, + MinBid: e.MinBid, + BuilderBoostFactor: e.BoostFactor, + }) + } + 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 new file mode 100644 index 0000000000..e84a469808 --- /dev/null +++ b/protocol/v2/types/gloas/produce_builder_config_test.go @@ -0,0 +1,81 @@ +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, + } + + 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") + 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(resolved, 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 +} + +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)) +} diff --git a/protocol/v2/types/gloas/proposer_preferences.go b/protocol/v2/types/gloas/proposer_preferences.go new file mode 100644 index 0000000000..d0780a5dbb --- /dev/null +++ b/protocol/v2/types/gloas/proposer_preferences.go @@ -0,0 +1,122 @@ +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" + +// 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 +// 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/protocol/v2/types/gloas/ptc_duty.go b/protocol/v2/types/gloas/ptc_duty.go new file mode 100644 index 0000000000..6cf5f450dd --- /dev/null +++ b/protocol/v2/types/gloas/ptc_duty.go @@ -0,0 +1,56 @@ +package gloas + +import ( + "encoding/json" + "fmt" + "strconv" + + "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) + } + if err := decodeHexInto(d.PubKey[:], data.PubKey, "pubkey"); err != nil { + return err + } + 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) +} diff --git a/protocol/v2/types/gloas/request_auth.go b/protocol/v2/types/gloas/request_auth.go new file mode 100644 index 0000000000..d079f90569 --- /dev/null +++ b/protocol/v2/types/gloas/request_auth.go @@ -0,0 +1,106 @@ +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 BuilderRequestAuth,SignedBuilderRequestAuth" + +// MaxBuilderAuthDataSize is builder-specs' MAX_BUILDER_AUTH_DATA_SIZE: the ByteList limit of BuilderRequestAuth.Data. +const MaxBuilderAuthDataSize = 4096 + +// 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 +// 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 +} + +// 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 SignedBuilderRequestAuth struct { + Message *BuilderRequestAuth + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// builderRequestAuthJSON is the builder-API JSON form: uint64 as a decimal string, data as 0x-hex, per +// go-eth2-client conventions. +type builderRequestAuthJSON struct { + Data string `json:"data"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +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 *BuilderRequestAuth) UnmarshalJSON(input []byte) error { + var data builderRequestAuthJSON + 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) > 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) + if err != nil { + return fmt.Errorf("invalid value for slot: %w", err) + } + r.Slot = phase0.Slot(slot) + return nil +} + +// 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 *SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedBuilderRequestAuthJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +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) + } + 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..f5ab9a03ad --- /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 BuilderRequestAuth object +func (r *BuilderRequestAuth) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(r) +} + +// 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) + + // 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("BuilderRequestAuth.Data", size, 4096) + return + } + dst = append(dst, r.Data...) + + return +} + +// UnmarshalSSZ ssz unmarshals the BuilderRequestAuth object +func (r *BuilderRequestAuth) 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 BuilderRequestAuth object +func (r *BuilderRequestAuth) SizeSSZ() (size int) { + size = 12 + + // Field (0) 'Data' + size += len(r.Data) + + return +} + +// HashTreeRoot ssz hashes the BuilderRequestAuth object +func (r *BuilderRequestAuth) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(r) +} + +// HashTreeRootWith ssz hashes the BuilderRequestAuth object with a hasher +func (r *BuilderRequestAuth) 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 BuilderRequestAuth object +func (r *BuilderRequestAuth) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(r) +} + +// MarshalSSZ ssz marshals the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(s) +} + +// 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) + + // 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 SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) 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(BuilderRequestAuth) + } + if err = s.Message.UnmarshalSSZ(buf); err != nil { + return err + } + } + return err +} + +// 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(BuilderRequestAuth) + } + size += s.Message.SizeSSZ() + + return +} + +// HashTreeRoot ssz hashes the SignedBuilderRequestAuth object +func (s *SignedBuilderRequestAuth) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(s) +} + +// HashTreeRootWith ssz hashes the SignedBuilderRequestAuth object with a hasher +func (s *SignedBuilderRequestAuth) 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 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 new file mode 100644 index 0000000000..ae06602420 --- /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 TestBuilderRequestAuth_SSZ(t *testing.T) { + r := &BuilderRequestAuth{ + 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 BuilderRequestAuth + 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 TestBuilderRequestAuth_SSZ_DataLimit(t *testing.T) { + // At the ByteList limit both directions succeed. + atLimit := &BuilderRequestAuth{Data: make([]byte, MaxBuilderAuthDataSize), Slot: 1} + b, err := atLimit.MarshalSSZ() + require.NoError(t, err) + var dec BuilderRequestAuth + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Len(t, dec.Data, MaxBuilderAuthDataSize) + + // One byte over: marshal of the oversize object and unmarshal of an oversize tail both fail. + 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)) +} + +// 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 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 := &BuilderRequestAuth{Slot: 1} + htr, err = empty.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, + "0xa5b4c560790a4fbfd24ad385f1353d605987bbbf53549e314241a3093986e773", + phase0.Root(htr).String()) +} + +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. + require.Equal(t, 100+12+len(s.Message.Data), s.SizeSSZ()) + + b, err := s.MarshalSSZ() + require.NoError(t, err) + + var dec SignedBuilderRequestAuth + require.NoError(t, dec.UnmarshalSSZ(b)) + require.Equal(t, s, &dec) +} + +// 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 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) + + 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 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 BuilderRequestAuth + require.NoError(t, json.Unmarshal(out, &dec)) + require.Equal(t, r, &dec) + + // Empty data round-trips as "0x". + 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, 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 TestSignedBuilderRequestAuth_JSON_MessageMissing(t *testing.T) { + var s SignedBuilderRequestAuth + require.ErrorContains(t, json.Unmarshal([]byte(`{"signature":"0x00"}`), &s), "message missing") +} 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 0000000000..e6018790a1 Binary files /dev/null and b/protocol/v2/types/gloas/testdata/devnet6_gloas_block.ssz differ diff --git a/protocol/v2/types/gloas/testing.go b/protocol/v2/types/gloas/testing.go new file mode 100644 index 0000000000..4cc720cf6f --- /dev/null +++ b/protocol/v2/types/gloas/testing.go @@ -0,0 +1,21 @@ +package gloas + +import ( + "github.com/attestantio/go-eth2-client/spec/altair" + "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: &ExecutionRequests{}, + }, + } +} diff --git a/protocol/v2/types/runner_role_test.go b/protocol/v2/types/runner_role_test.go index 228133d792..68623ec8df 100644 --- a/protocol/v2/types/runner_role_test.go +++ b/protocol/v2/types/runner_role_test.go @@ -74,3 +74,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: 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(). + proposer := &spectypes.ValidatorDuty{Type: spectypes.BNRoleProposer} + require.Equal(t, spectypes.RoleProposer, RunnerRoleForValidatorDuty(proposer, true)) +} 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/ekm/local_key_manager.go b/ssvsigner/ekm/local_key_manager.go index f268191f12..d4db110da3 100644 --- a/ssvsigner/ekm/local_key_manager.go +++ b/ssvsigner/ekm/local_key_manager.go @@ -55,6 +55,20 @@ 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 + // RLocked during signing, so without this two concurrent same-validator block signs could both pass. + 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. @@ -102,6 +116,7 @@ func NewLocalKeyManager( signer: beaconSigner, slashingProtector: NewSlashingProtector(logger, beacon, signerStore, protection), operatorDecrypter: operatorPrivKey, + beaconConfig: beacon, }, nil } @@ -119,6 +134,8 @@ func (km *LocalKeyManager) SignBeaconObject( _ phase0.Slot, signatureDomain phase0.DomainType, ) (spectypes.Signature, phase0.Root, error) { + // 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 @@ -189,7 +206,32 @@ 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. 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, blockSlot); err != nil { + return nil, nil, err + } + if err := km.slashingProtector.UpdateHighestProposal(pubKey, blockSlot); err != nil { + return nil, nil, err + } + return signSSZRoot(km.signer, obj, domain, pubKey[:]) } case spectypes.DomainVoluntaryExit: @@ -244,11 +286,26 @@ 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.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 + // DomainBuilderRequestAuth (builder-specs BuilderRequestAuth, the direct-builder request auth). + 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/local_key_manager_test.go b/ssvsigner/ekm/local_key_manager_test.go index 6b159ae9fa..063f01da69 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" @@ -311,6 +312,107 @@ 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". + for _, tc := range []struct { + name string + domain phase0.DomainType + }{ + {"DomainBeaconBuilder", spectypes.DomainBeaconBuilder}, + {"DomainPTCAttester", spectypes.DomainPTCAttester}, + {"DomainProposerPreferences", spectypes.DomainProposerPreferences}, + {"DomainBuilderRequestAuth", spectypes.DomainBuilderRequestAuth}, + } { + 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) + }) + } +} + +// 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.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) + + 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(t.Context(), nil, encryptedSK1, pk)) + + return km.(*LocalKeyManager), pk +} + +func TestSignBeaconObjectGloasBlockSlashingProtection(t *testing.T) { + ctx := t.Context() + lkm, pk := newLocalKeyManagerWithShare(t) + + // 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) + + 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, 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) { diff --git a/ssvsigner/ekm/remote_key_manager.go b/ssvsigner/ekm/remote_key_manager.go index ad7af633a1..980c0ccc7d 100644 --- a/ssvsigner/ekm/remote_key_manager.go +++ b/ssvsigner/ekm/remote_key_manager.go @@ -402,6 +402,32 @@ 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 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 (#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 (#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 (#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 (#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") } @@ -471,8 +497,30 @@ 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, 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 +// (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 { + 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..c4b45b7751 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() diff --git a/ssvsigner/go.mod b/ssvsigner/go.mod index 746981156f..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.20260305184636-289c93aa4c12 + 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 e9456d62eb..25a80c414b 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.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= 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,