Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/ethereum/go-ethereum v1.17.4
github.com/ethpandaops/ethwallclock v0.4.0
github.com/ethpandaops/go-eth2-client v0.1.6
github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e
github.com/ethpandaops/service-authenticatoor v0.0.2
github.com/ethpandaops/spamoor v1.2.2
github.com/glebarez/go-sqlite v1.22.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y
github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24=
github.com/ethpandaops/go-eth2-client v0.1.6 h1:lG7Xz767YQQ+mN1ldzqJlj9Klg6zOElcemjXbCgJV5o=
github.com/ethpandaops/go-eth2-client v0.1.6/go.mod h1:97Oq3omOQSGPPYgrbsOIIw2Pc4T5Ph21f8ZRyHJQBHU=
github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e h1:UagcSXTMo6fkStgPbzQPGhtSS1+m4kApMnlsJMRW1uc=
github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e/go.mod h1:qcaftWjJJcrZ/6MgmHBRdfMmUK6IygbBpT8w3TIuVPs=
github.com/ethpandaops/service-authenticatoor v0.0.2 h1:0rqHA2Rw64+NG0HVGRfqVhJEkRZRzH3qhX2SkBr2u+w=
github.com/ethpandaops/service-authenticatoor v0.0.2/go.mod h1:nIInMlq5O7YQDLVCL5gf0TeVAvVd9Q50k3sm3abTeG0=
github.com/ethpandaops/spamoor v1.2.2 h1:hkTVJA8jRTWsR0IvH1WsQcq5cWOui4ikOya7mYZ7B6g=
Expand Down
8 changes: 4 additions & 4 deletions pkg/clients/consensus/blockcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ func (cache *BlockCache) SetClientSpecs(specValues map[string]interface{}) error
cache.specMutex.Lock()
defer cache.specMutex.Unlock()

specs := ChainSpec{}
specs := NewChainSpec()

err := smapping.FillStructByTags(&specs, specValues, "yaml")
err := smapping.FillStructByTags(specs, specValues, "yaml")
if err != nil {
return err
}
Expand All @@ -169,13 +169,13 @@ func (cache *BlockCache) SetClientSpecs(specValues map[string]interface{}) error
}

if cache.specs != nil {
mismatches := cache.specs.CheckMismatch(&specs)
mismatches := cache.specs.CheckMismatch(specs)
if len(mismatches) > 0 {
return fmt.Errorf("spec mismatch: %v", strings.Join(mismatches, ", "))
}
}

cache.specs = &specs
cache.specs = specs
cache.fullSpecs = specValues

return nil
Expand Down
63 changes: 58 additions & 5 deletions pkg/clients/consensus/chainspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"reflect"
"time"

"github.com/ethpandaops/go-eth2-client/spec"
"github.com/ethpandaops/go-eth2-client/spec/phase0"
)

Expand All @@ -29,18 +30,70 @@ type ChainSpec struct {
ElectraForkEpoch uint64 `yaml:"ELECTRA_FORK_EPOCH"`
FuluForkEpoch uint64 `yaml:"FULU_FORK_EPOCH"`
GloasForkEpoch uint64 `yaml:"GLOAS_FORK_EPOCH"`
HezeForkEpoch uint64 `yaml:"HEZE_FORK_EPOCH"`
SlotDurationMs uint64 `yaml:"SLOT_DURATION_MS"`
SlotsPerEpoch uint64 `yaml:"SLOTS_PER_EPOCH"`
MaxCommitteesPerSlot uint64 `yaml:"MAX_COMMITTEES_PER_SLOT"`
}

// FarFutureEpoch marks a fork that is not scheduled on this chain. Beacon nodes
// report it for known-but-unscheduled forks and omit the key entirely for forks
// they predate; NewChainSpec seeds every fork epoch with it so both cases read
// the same, and a reported epoch of 0 keeps its literal meaning of "genesis".
const FarFutureEpoch = ^uint64(0)

// NewChainSpec returns a ChainSpec with every fork unscheduled, ready to be
// filled from a beacon node's /eth/v1/config/spec response.
func NewChainSpec() *ChainSpec {
return &ChainSpec{
AltairForkEpoch: FarFutureEpoch,
BellatrixForkEpoch: FarFutureEpoch,
CappellaForkEpoch: FarFutureEpoch,
DenebForkEpoch: FarFutureEpoch,
ElectraForkEpoch: FarFutureEpoch,
FuluForkEpoch: FarFutureEpoch,
GloasForkEpoch: FarFutureEpoch,
HezeForkEpoch: FarFutureEpoch,
}
}

// isForkActive reports whether the fork scheduled at forkEpoch is active at the
// given slot. Unscheduled forks carry FarFutureEpoch (see NewChainSpec), so a
// fork epoch of 0 correctly reads as "active since genesis". The comparison
// divides rather than multiplying, so FarFutureEpoch cannot overflow it.
func (chain *ChainSpec) isForkActive(forkEpoch uint64, slot phase0.Slot) bool {
return chain.SlotsPerEpoch > 0 && uint64(slot)/chain.SlotsPerEpoch >= forkEpoch
}

// IsGloasActive returns true if the gloas fork is active at the given slot.
func (chain *ChainSpec) IsGloasActive(slot phase0.Slot) bool {
if chain.GloasForkEpoch == 0 || chain.SlotsPerEpoch == 0 {
return false
}
return chain.isForkActive(chain.GloasForkEpoch, slot)
}

return uint64(slot) >= chain.GloasForkEpoch*chain.SlotsPerEpoch
// DataVersionAtSlot returns the consensus fork active at the given slot, as the
// spec.DataVersion that selects the arm of a versioned type and drives the
// Eth-Consensus-Version header.
func (chain *ChainSpec) DataVersionAtSlot(slot phase0.Slot) spec.DataVersion {
switch {
case chain.isForkActive(chain.HezeForkEpoch, slot):
return spec.DataVersionHeze
case chain.isForkActive(chain.GloasForkEpoch, slot):
return spec.DataVersionGloas
case chain.isForkActive(chain.FuluForkEpoch, slot):
return spec.DataVersionFulu
case chain.isForkActive(chain.ElectraForkEpoch, slot):
return spec.DataVersionElectra
case chain.isForkActive(chain.DenebForkEpoch, slot):
return spec.DataVersionDeneb
case chain.isForkActive(chain.CappellaForkEpoch, slot):
return spec.DataVersionCapella
case chain.isForkActive(chain.BellatrixForkEpoch, slot):
return spec.DataVersionBellatrix
case chain.isForkActive(chain.AltairForkEpoch, slot):
return spec.DataVersionAltair
default:
return spec.DataVersionPhase0
}
}

func (chain *ChainSpec) CheckMismatch(chain2 *ChainSpec) []string {
Expand All @@ -49,7 +102,7 @@ func (chain *ChainSpec) CheckMismatch(chain2 *ChainSpec) []string {
chainT := reflect.ValueOf(chain).Elem()
chain2T := reflect.ValueOf(chain2).Elem()

for i := 0; i < chainT.NumField(); i++ {
for i := range chainT.NumField() {
if chainT.Field(i).Interface() != chain2T.Field(i).Interface() {
mismatches = append(mismatches, chainT.Type().Field(i).Name)
}
Expand Down
115 changes: 115 additions & 0 deletions pkg/clients/consensus/chainspec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package consensus

import (
"context"
"testing"

"github.com/ethpandaops/go-eth2-client/spec"
"github.com/ethpandaops/go-eth2-client/spec/phase0"
"github.com/sirupsen/logrus"
)

func TestDataVersionAtSlot(t *testing.T) {
// Every fork scheduled, one epoch apart, so each rung of the ladder is
// reachable and the boundaries are exact.
scheduled := &ChainSpec{
AltairForkEpoch: 1,
BellatrixForkEpoch: 2,
CappellaForkEpoch: 3,
DenebForkEpoch: 4,
ElectraForkEpoch: 5,
FuluForkEpoch: 6,
GloasForkEpoch: 7,
HezeForkEpoch: 8,
SlotsPerEpoch: 32,
}

// A chain that never leaves electra: later forks unscheduled, as a beacon
// node reports them.
electraOnly := &ChainSpec{
AltairForkEpoch: 0,
BellatrixForkEpoch: 0,
CappellaForkEpoch: 0,
DenebForkEpoch: 0,
ElectraForkEpoch: 0,
FuluForkEpoch: FarFutureEpoch,
GloasForkEpoch: FarFutureEpoch,
HezeForkEpoch: FarFutureEpoch,
SlotsPerEpoch: 32,
}

tests := []struct {
name string
spec *ChainSpec
slot phase0.Slot
expected spec.DataVersion
}{
{name: "Phase0BeforeAltair", spec: scheduled, slot: 0, expected: spec.DataVersionPhase0},
{name: "Altair", spec: scheduled, slot: 1 * 32, expected: spec.DataVersionAltair},
{name: "Bellatrix", spec: scheduled, slot: 2 * 32, expected: spec.DataVersionBellatrix},
{name: "Capella", spec: scheduled, slot: 3 * 32, expected: spec.DataVersionCapella},
{name: "Deneb", spec: scheduled, slot: 4 * 32, expected: spec.DataVersionDeneb},
{name: "DenebUntilElectra", spec: scheduled, slot: 5*32 - 1, expected: spec.DataVersionDeneb},
{name: "Electra", spec: scheduled, slot: 5 * 32, expected: spec.DataVersionElectra},
{name: "Fulu", spec: scheduled, slot: 6 * 32, expected: spec.DataVersionFulu},
{name: "Gloas", spec: scheduled, slot: 7 * 32, expected: spec.DataVersionGloas},
{name: "Heze", spec: scheduled, slot: 8 * 32, expected: spec.DataVersionHeze},
{name: "HezeStaysAtHead", spec: scheduled, slot: 500 * 32, expected: spec.DataVersionHeze},

// A fork epoch of 0 means genesis, not "unscheduled".
{name: "GenesisElectra", spec: electraOnly, slot: 0, expected: spec.DataVersionElectra},
{name: "UnscheduledForksNeverActivate", spec: electraOnly, slot: 1e6, expected: spec.DataVersionElectra},

{
// SlotsPerEpoch is only zero before the spec has been fetched.
name: "ZeroSlotsPerEpoch",
spec: &ChainSpec{ElectraForkEpoch: 0},
slot: 100 * 32,
expected: spec.DataVersionPhase0,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.spec.DataVersionAtSlot(test.slot); got != test.expected {
t.Errorf("DataVersionAtSlot(%d) = %v, want %v", test.slot, got, test.expected)
}
})
}
}

// TestSetClientSpecsForkDefaults pins the distinction the fork ladder rests on:
// a fork epoch a beacon node does not report at all must read as unscheduled,
// while a reported 0 must read as genesis. Both arrive as the same Go zero
// value unless the spec is seeded first.
func TestSetClientSpecsForkDefaults(t *testing.T) {
cache, err := NewBlockCache(context.Background(), logrus.New(), 10)
if err != nil {
t.Fatalf("failed creating block cache: %v", err)
}

// A node that knows nothing past electra omits FULU/GLOAS/HEZE entirely.
err = cache.SetClientSpecs(map[string]interface{}{
"SLOTS_PER_EPOCH": uint64(32),
"ALTAIR_FORK_EPOCH": uint64(0),
"DENEB_FORK_EPOCH": uint64(0),
"ELECTRA_FORK_EPOCH": uint64(0),
})
if err != nil {
t.Fatalf("failed setting client specs: %v", err)
}

specs := cache.GetSpecs()

if specs.FuluForkEpoch != FarFutureEpoch {
t.Errorf("unreported FuluForkEpoch = %d, want FarFutureEpoch", specs.FuluForkEpoch)
}

if specs.ElectraForkEpoch != 0 {
t.Errorf("reported ElectraForkEpoch = %d, want 0", specs.ElectraForkEpoch)
}

if got := specs.DataVersionAtSlot(1e6); got != spec.DataVersionElectra {
t.Errorf("DataVersionAtSlot = %v, want electra: unreported forks must not activate", got)
}
}
38 changes: 20 additions & 18 deletions pkg/clients/consensus/rpc/beaconapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
nethttp "net/http"
Expand Down Expand Up @@ -510,28 +511,29 @@ func (bc *BeaconClient) SubmitVoluntaryExits(ctx context.Context, exit *phase0.S
return nil
}

func (bc *BeaconClient) SubmitAttesterSlashing(ctx context.Context, slashing *phase0.AttesterSlashing) error {
err := bc.postJSON(ctx, fmt.Sprintf("%s/eth/v1/beacon/pool/attester_slashings", bc.endpoint), slashing, nil)
if err == nil {
return nil
}
// ErrPreElectraSlashing reports an attester slashing whose fork predates
// electra. The versioned pool endpoint was introduced in the electra release,
// so a node on an earlier fork does not serve it.
var ErrPreElectraSlashing = errors.New("attester slashing predates the versioned pool endpoint")

// Prysm removed the v1 endpoint post-Electra, fall back to v2 with version header.
if err.Error() == "not found" {
v2Err := bc.postJSONWithHeaders(
ctx,
fmt.Sprintf("%s/eth/v2/beacon/pool/attester_slashings", bc.endpoint),
slashing, nil,
map[string]string{"Eth-Consensus-Version": "electra"},
)
if v2Err != nil {
return v2Err
}
// SubmitAttesterSlashing submits a versioned attester slashing through
// go-eth2-client's V2 pool endpoint (/eth/v2/beacon/pool/attester_slashings).
// The client library derives the Eth-Consensus-Version header from the
// slashing's version. The V1 endpoint was deprecated in the electra release of
// the beacon APIs and removed from the spec (ethereum/beacon-APIs#549).
func (bc *BeaconClient) SubmitAttesterSlashing(ctx context.Context, slashing *spec.VersionedAttesterSlashing) error {
if slashing.Version < spec.DataVersionElectra {
return fmt.Errorf("%w: fork is %v", ErrPreElectraSlashing, slashing.Version)
}

return nil
submitter, isOk := bc.clientSvc.(eth2client.AttesterSlashingSubmitterV2)
if !isOk {
return fmt.Errorf("submit attester slashing not supported")
}

return err
return submitter.SubmitAttesterSlashingV2(ctx, &api.SubmitAttesterSlashingOpts{
AttesterSlashing: slashing,
})
}

func (bc *BeaconClient) SubmitProposerSlashing(ctx context.Context, slashing *phase0.ProposerSlashing) error {
Expand Down
Loading
Loading